Recognise an edit envelope so it is not drawn as a duplicate (§6.15) #39
@@ -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