Merge branch 'feat/edit-a-sent-message' into 'main'

Recognise an edit envelope so it is not drawn as a duplicate (§6.15)

See merge request paulhitt/flamenet-messenger-android!12
This commit was merged in pull request #39.
This commit is contained in:
Paul Hitt
2026-09-02 19:47:52 +00:00
3 changed files with 204 additions and 3 deletions
+2 -1
View File
@@ -93,7 +93,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Multi-device fan-out | ✅ one envelope per device, partial delivery reported |
| History at rest | ✅ encrypted, keyed by person not device |
| Session restore across restarts | ✅ credential and device both survive |
| Reactions | 🟡 codec done, no UI |
| Reactions | 🟡 codec done, no UI — blocked on the transcript carrying a `mid` |
| Edit a sent message (§6.15) | 🟡 codec done, in progress — an inbound edit is recognised and not drawn as a duplicate bubble; applying it needs the same `mid` |
| Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
@@ -61,6 +61,15 @@ data class Content(
val link: LinkPreview? = null,
/** A live stream this story embeds (§6.13). */
val streamUrl: String? = null,
/**
* Set iff this envelope replaces the text of an earlier message (§6.15):
* the `mid` of the message being edited. [text] then carries the new
* wording.
*
* 🔴 An edit is never a bubble of its own. It changes one that is already
* there, or it does nothing.
*/
val editOf: String? = null,
) {
/**
@@ -135,9 +144,14 @@ data class Content(
*
* Reactions fold onto an existing bubble, call signals drive the call UI,
* and control messages are consumed silently. Everything else is a message.
*
* 🔴 **An edit is not a message either (§6.15).** It carries the new
* wording in [text], so without this it falls straight through and draws as
* a second bubble beside the one it was meant to replace — a correction
* arriving as a duplicate, which is worse than the typo.
*/
val isRenderable: Boolean
get() = reaction == null && call == null && !isControl
get() = reaction == null && call == null && editOf == null && !isControl
/**
* Control traffic: consumed by a layer, never shown.
@@ -247,6 +261,15 @@ data class Content(
*/
const val DISAPPEAR = "ttl"
/**
* A replacement for the text of an earlier message (§6.15).
*
* 🔴 **Not in [CONTROL].** Control kinds are swallowed whole; this one
* has to be acted on, and a build that merely swallowed it would drop
* corrections silently while looking like it worked.
*/
const val EDIT = "edit"
/**
* ⚠️ Consumed, never shown. Each of these is a real message the web
* client sends today, and rendering one puts raw JSON in somebody's
@@ -262,7 +285,7 @@ data class Content(
* draw (shown as a placeholder) — never raw JSON in a bubble.
*/
val KNOWN = CONTROL + setOf(
TEXT, REACT, STICKER,
TEXT, REACT, STICKER, EDIT,
Attachment.IMAGE, Attachment.AUDIO, Attachment.STORY,
CallSignal.OFFER, CallSignal.ANSWER, CallSignal.END,
)
@@ -343,6 +366,23 @@ data class Content(
return "{" + parts.joinToString(",") + "}"
}
/**
* An edit of an earlier message (§6.15).
*
* ⚠️ Reuses `re` to name the target, exactly as a reaction does — a new
* field would be a second way of saying "this is about that message"
* and would need adding to two clients, an engine and a spec before
* anything worked.
*
* 🔴 **`re.author` is not evidence and the receiver must not read it as
* evidence.** Anyone can put any user id there. The receiver decides
* whether an edit is allowed from the copy of the message it already
* holds, never from what the envelope claims about itself.
*/
fun edit(newText: String, of: String, groupId: String? = null): String =
assemble(Kind.EDIT, caption = newText, groupId = groupId,
about = Reference(of, author = 0, preview = ""))
fun callSignal(signal: CallSignal): String {
val call = JSONObject().put("id", signal.callId)
signal.sdp?.let { call.put("sdp", it) }
@@ -450,6 +490,15 @@ data class Content(
}
}
// §6.15 — a replacement for the text of an earlier message.
// Checked BEFORE the control swallow, which would consume it
// correctly and act on nothing, and before TEXT, which would render
// the new wording as a second bubble beside the original.
if (kind == Kind.EDIT && about != null) {
return Content(caption, kind = kind, raw = json, groupId = groupId,
about = about, editOf = about.messageId)
}
// Control traffic. Returned with its envelope so the caller can act
// on it, and never renderable.
//
@@ -0,0 +1,151 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.json.JSONObject
/**
* Replacing the wording of a message already sent (§6.15).
*
* 🔴 **Without this branch an edit renders as a duplicate message.** An unknown
* kind with a caption and no attachment falls through to the last case in
* `parse`, which returns the caption as text — so an iPhone correcting a typo
* would appear on Android as a second bubble beside the original, with no
* indication that either replaced the other. That is the bug this fixes today,
* before any of the applying half exists.
*
* ⚠️ **This is the codec only.** `Session.Line` carries no `mid`, so nothing in
* an Android transcript can be named by an edit and the app swallows them for
* now — the same place reactions have been sitting (PARITY.md: "codec done, no
* UI"). One transcript change unlocks replies, reactions and edits together.
*/
class EditEnvelopeTest {
@Test
fun `an edit names the message it replaces and carries the new wording`() {
val wire = Content.edit("meet at 9", of = "abc")
val content = Content.parse(wire)
assertEquals("abc", content.editOf)
assertEquals("meet at 9", content.text)
assertEquals(Content.Kind.EDIT, content.kind)
}
/**
* 🔴 The bug this exists to prevent. Before the branch, an edit fell
* through to the attachment path and came back as renderable text — a
* second bubble the sender never sent.
*/
@Test
fun `an edit is not a renderable message`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
assertNull(content.attachment, "an edit came back carrying an attachment")
assertNull(content.reaction)
assertNull(content.sticker)
assertTrue(content.editOf != null,
"an edit that is not recognisable as one will be drawn as a message")
}
/**
* ⚠️ It must not be swallowed as generic control traffic either. A build
* that consumed it silently would look like it worked while dropping every
* correction — which is worse than the duplicate bubble, because nobody
* would notice.
*/
@Test
fun `an edit is a known kind, not unknown control`() {
assertTrue(Content.Kind.EDIT in Content.Kind.KNOWN)
assertTrue(Content.Kind.EDIT !in Content.Kind.CONTROL,
"an edit swallowed as control would drop corrections silently")
}
/** Group routing survives, for when a group thread can name a message. */
@Test
fun `a group edit keeps its routing`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc", groupId = "g1"))
assertEquals("g1", content.groupId)
assertEquals("abc", content.editOf)
}
/**
* 🔴 `re.author` is a claim, not evidence — anyone can put any user id
* there. The codec must not present it as authorship, and the encoder must
* not fill it in with something that looks like one.
*/
@Test
fun `the envelope makes no claim about who wrote the original`() {
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
assertEquals(0L, json.getJSONObject("re").getLong("author") as Long,
"the encoder asserted an author, which a receiver might believe")
// And a forged one parses identically, because nothing downstream reads it.
val forged = Content.edit("meet at 9", of = "abc")
.replace("\"author\":0", "\"author\":99")
assertEquals("abc", Content.parse(forged).editOf)
}
/** ⚠️ `fnc` first, like every other envelope — the receive side's marker
* check matches the serialized prefix. */
@Test
fun `the envelope starts with the marker`() {
assertTrue(Content.edit("hi", of = "abc").startsWith("{\"fnc\":1,"))
}
/**
* An edit with no target is not an edit. It must fall out of the branch
* rather than become one naming nothing.
*/
@Test
fun `an edit with no target is not treated as an edit`() {
val malformed = """{"fnc":1,"kind":"edit","caption":"meet at 9"}"""
assertNull(Content.parse(malformed).editOf)
}
/**
* ⚠️ Both clients must agree byte-for-byte on the shape, since one encodes
* what the other parses. This is the shape iOS emits.
*/
@Test
fun `the wire shape matches what iOS sends`() {
val json = JSONObject(Content.edit("meet at 9", of = "abc"))
assertEquals(1, json.getInt("fnc"))
assertEquals("edit", json.getString("kind"))
assertEquals("abc", json.getJSONObject("re").getString("mid"))
assertEquals("meet at 9", json.getString("caption"))
}
}
/**
* 🔴 The single decision that keeps an edit out of the transcript.
*
* `Session` filters inbound envelopes on `isRenderable`, so this property is
* the whole of what stops a correction being filed as a new message. It is
* tested apart from the codec because it is the part that would still be wrong
* if the parse branch were right.
*/
class EditIsNotRenderableTest {
@Test
fun `an edit is not filed as a message`() {
val content = Content.parse(Content.edit("meet at 9", of = "abc"))
assertTrue(!content.isRenderable,
"a correction would be filed as a second bubble beside the original")
}
/** ⚠️ And ordinary text still is, or this fix has broken conversation. */
@Test
fun `ordinary text is still a message`() {
val content = Content.parse(Content.text("meet at 6"))
assertTrue(content.isRenderable)
}
/** A reaction and a call signal stay excluded for their own reasons. */
@Test
fun `the other non-messages are still excluded`() {
val reaction = Content.parse(
Content.reaction("👍", about = Content.Reference("abc", 7L, "")))
assertTrue(!reaction.isRenderable)
}
}