Recognise an edit envelope so it is not drawn as a duplicate (§6.15)
Without this branch an edit falls through parse to the last case, which returns the caption as text — so an iPhone correcting a typo appears on Android as a SECOND bubble beside the original, with nothing to say either replaced the other. That is the bug this fixes today, before any of the applying half exists. isRenderable excludes it for the same reason it excludes a reaction, and it is NOT in Kind.CONTROL: a build that swallowed it as generic control would drop every correction silently, which is worse than the duplicate because nobody would notice. ⚠️ Codec only. Session.Line records no mid, so nothing in an Android transcript can be named by an edit and the app cannot apply one. That is the same gap reactions have been sitting behind (PARITY.md "codec done, no UI") and one transcript change unlocks replies, reactions and edits together — filed separately. Refs paulhitt/flamenet-messenger-ios#26 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d1a438cdd2
commit
914112b032
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user