Apply inbound edits and pins instead of filing them as messages (§6.15/#6.16/#6.19)
🔴 This was an interop bug, not a missing feature. The engine has parsed `editOf`, `pins` and `fwd` for as long as they have existed, and it exposes `isRenderable` — "does this belong in a thread" — as the single answer. `Session`'s inbound loop never consulted it. So three things arrived from the iPhone app and were filed as ordinary bubbles. An edit carries its new wording in `text`, so it drew as a DUPLICATE beside the typo it was meant to replace — a correction that made the thread worse than the mistake did. A pin carries only an id, so it drew as a blank bubble. And a forward was drawn as the sender's own words. Worst of the three is the one nobody had hit yet: a control envelope from a NEWER peer carries `ctl` and a kind this build has never heard of, and would have drawn a bubble full of JSON in somebody's conversation. That is precisely the failure `isControl` was written to prevent, defeated by nobody calling it — and it would have shipped to every older Android install the first time a control kind was added on iOS. The guard is `isRenderable` rather than a list of kinds this build knows, because an allowlist is only correct until the other platform adds one. What is applied now: - an edit rewrites the message it names and stamps `edited`, so a bubble can say so — silently replacing the text is worse than the typo, because the reader cannot tell a correction from their own misremembering; - a pin is stored per thread, sealed in the vault like the transcript, as an ID and never a copy: a copy goes stale against an edit and outlives a deletion; - `fwd` reaches the transcript so second-hand words are not drawn as the sender's own — THAT it was forwarded, never from whom. 🔴 An inbound edit is applied only when it comes from the person whose message it is. Without that check any contact sharing a session could rewrite anybody's words in the reader's transcript — a better attack than sending a message of their own, because the words would carry somebody else's name. ⚠️ Clearing a thread clears its pin. A banner pointing at a message that was just cleared reads as the clear not having worked. ⚠️ `fwd` is read as both a boolean and a number, for the same reason `isControl` accepts both: `org.json`'s `optBoolean` does not coerce a number, and that is how this class of bug shipped once already. 9 unit tests. App builds, every JVM suite green. The three PARITY rows stay 🟡: the inbound half is done, originating each still needs the UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab8ff658b2
commit
e0420a00c6
@@ -116,6 +116,21 @@ class Session(
|
||||
* to know anything had been dropped.
|
||||
*/
|
||||
val about: Content.Reference? = null,
|
||||
/**
|
||||
* Second-hand words (§6.16).
|
||||
*
|
||||
* ⚠️ THAT it was forwarded, never from whom.
|
||||
*/
|
||||
val forwarded: Boolean = false,
|
||||
/**
|
||||
* When this message was last edited (§6.15), or null if never.
|
||||
*
|
||||
* 🔴 **Kept so the bubble can say "edited".** A correction that silently
|
||||
* replaces the text is worse than the typo: the reader has no way to
|
||||
* know the words changed after they read them, and no way to tell a
|
||||
* correction from their own misremembering.
|
||||
*/
|
||||
val editedAt: Long? = null,
|
||||
/**
|
||||
* A timer this one message carries, in seconds (§6.17); 0 for none.
|
||||
*
|
||||
@@ -163,6 +178,12 @@ class Session(
|
||||
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
|
||||
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
|
||||
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
|
||||
// ⚠️ Both shapes, for the same reason `Content.isForwarded`
|
||||
// accepts both: `org.json`'s `optBoolean` does not coerce a
|
||||
// number, so reading only that would miss a `"fwd":1` written
|
||||
// by any other engine.
|
||||
forwarded = m.optBoolean("fwd", false) || m.optInt("fwd", 0) != 0,
|
||||
editedAt = m.optLong("edited", 0L).takeIf { it > 0L },
|
||||
lifetimeSeconds = m.optInt("secs", 0),
|
||||
)
|
||||
}
|
||||
@@ -184,6 +205,15 @@ class Session(
|
||||
about: Content.Reference? = null,
|
||||
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
|
||||
lifetimeSeconds: Int = 0,
|
||||
/**
|
||||
* Whether the sender is passing on somebody else's words (§6.16).
|
||||
*
|
||||
* ⚠️ Says THAT it was forwarded and never from whom — the point of a
|
||||
* forward is that the original author does not travel with it. This
|
||||
* exists so second-hand words are not shown as the sender's own, which
|
||||
* is a different thing from attribution.
|
||||
*/
|
||||
forwarded: Boolean = false,
|
||||
) {
|
||||
vault.append(threadId, JSONObject()
|
||||
.put("author", author)
|
||||
@@ -201,10 +231,10 @@ class Session(
|
||||
}
|
||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
||||
.also { if (link != null) it.put("lnk", link.toJson()) }
|
||||
// ⚠️ Written only when present. A null on every message would grow
|
||||
// the transcript for nothing and make "no id" indistinguishable
|
||||
// from "an id of nothing".
|
||||
.also { if (messageId != null) it.put("mid", messageId) }
|
||||
// ⚠️ Written only when true, for the same reason `secs` is. A false
|
||||
// on every message grows the transcript for a flag that is almost
|
||||
// always absent.
|
||||
.also { if (forwarded) it.put("fwd", true) }
|
||||
.also { if (about != null) it.put("re", about.toJson()) })
|
||||
|
||||
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||
@@ -627,6 +657,70 @@ class Session(
|
||||
return hit
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an inbound edit to the message it names (§6.15).
|
||||
*
|
||||
* 🔴 **An edit is never a bubble of its own.** It changes one that is
|
||||
* already there, or it does nothing. Android had the codec and no
|
||||
* application, so an edit from the iPhone app arrived carrying the new
|
||||
* wording in `text` and was filed as an ORDINARY MESSAGE — a correction
|
||||
* showing up as a duplicate beside the typo it was meant to replace, which
|
||||
* is worse than the typo.
|
||||
*
|
||||
* 🔴 **Only the author may edit their own message.** Without this check any
|
||||
* contact sharing a session could rewrite anybody's words in the reader's
|
||||
* transcript, which is a far better attack than sending a message of their
|
||||
* own: the words would carry somebody else's name.
|
||||
*
|
||||
* Returns whether a message was found and changed.
|
||||
*/
|
||||
fun applyEdit(
|
||||
threadId: String,
|
||||
messageId: String,
|
||||
author: Long,
|
||||
newText: String,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val stored = vault.history(threadId)
|
||||
var hit = false
|
||||
for (i in 0 until stored.length()) {
|
||||
val row = stored.getJSONObject(i)
|
||||
if (row.optString("mid") != messageId) continue
|
||||
// 🔴 The author check. `mine` is this device's own message; an
|
||||
// inbound edit must come from the person whose message it is.
|
||||
val wroteIt = if (row.optBoolean("mine")) author == userId
|
||||
else row.optLong("author") == author
|
||||
if (!wroteIt) break
|
||||
row.put("text", newText).put("edited", at)
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if (hit) vault.replaceHistory(threadId, stored)
|
||||
return hit
|
||||
}
|
||||
|
||||
/**
|
||||
* The message pinned in a thread, or null (§6.19).
|
||||
*
|
||||
* ⚠️ An id, never a copy. A stored copy would go stale the moment the
|
||||
* message was edited, and would survive the message being deleted — a
|
||||
* banner quoting words that are no longer in the conversation.
|
||||
*/
|
||||
fun pinnedMessage(threadId: String): String? =
|
||||
vault.pinnedMessage(threadId)
|
||||
|
||||
/**
|
||||
* Pin a message, or take the pin down with an empty [messageId].
|
||||
*
|
||||
* ⚠️ **The last pin wins and there is only ever one.** A thread with a list
|
||||
* of pins is a second inbox; §6.19 is one banner, and two people pinning at
|
||||
* once resolve by whoever's envelope arrived last — the same rule the
|
||||
* transcript already uses for everything else.
|
||||
*/
|
||||
fun applyPin(threadId: String, messageId: String) {
|
||||
vault.savePinnedMessage(threadId, messageId.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
/**
|
||||
* React to somebody's message, or take it back with an empty [emoji].
|
||||
*
|
||||
@@ -924,6 +1018,37 @@ class Session(
|
||||
if (story.isActive(now)) incomingStories += story
|
||||
continue
|
||||
}
|
||||
// 🔴 **An EDIT (§6.15) changes a message; it is never one.**
|
||||
// Android had the codec and no application, so an edit from the
|
||||
// iPhone app fell straight through this loop carrying its new
|
||||
// wording in `text` and was filed as an ordinary bubble — a
|
||||
// correction arriving as a duplicate beside the typo it was meant
|
||||
// to replace, which is worse than the typo.
|
||||
message.content.editOf?.let { editing ->
|
||||
val gid = if (message.fromAddress != null) null
|
||||
else message.content.groupId
|
||||
val thread = gid?.let(Vault::threadForGroup)
|
||||
?: message.fromAddress?.let(Vault::threadForAddress)
|
||||
?: Vault.threadForUser(message.fromUser)
|
||||
if (applyEdit(thread, editing, message.fromUser,
|
||||
message.content.text.orEmpty())) {
|
||||
touched += thread
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 🔴 **A PIN (§6.19) is an id, not a bubble.** Same story: without
|
||||
// this it was filed as a message whose text was empty, so a pin
|
||||
// from the other app drew a blank bubble in the conversation.
|
||||
message.content.pins?.let { pinned ->
|
||||
val gid = if (message.fromAddress != null) null
|
||||
else message.content.groupId
|
||||
val thread = gid?.let(Vault::threadForGroup)
|
||||
?: message.fromAddress?.let(Vault::threadForAddress)
|
||||
?: Vault.threadForUser(message.fromUser)
|
||||
applyPin(thread, pinned)
|
||||
touched += thread
|
||||
continue
|
||||
}
|
||||
// A reaction to a MESSAGE. Folded onto the message it is about,
|
||||
// never filed as one of its own — a thread where every thumbs-up
|
||||
// is a bubble is a thread nobody can read.
|
||||
@@ -997,6 +1122,19 @@ class Session(
|
||||
// worse, match by coincidence. Groups do not federate yet, so a
|
||||
// remote sender's group claim is ignored and the message files in
|
||||
// their own thread.
|
||||
// 🔴 **The guard that stops this list needing to be complete.**
|
||||
// Every branch above names one kind of non-message. `isRenderable`
|
||||
// is the engine's own answer to "does this belong in a thread", and
|
||||
// it is false for reactions, call signals, edits, pins and — the
|
||||
// case that matters most — ANY control envelope from a newer peer,
|
||||
// which carries `ctl` and a kind this build has never heard of.
|
||||
//
|
||||
// Without it, a control kind added on iOS ships a bubble full of
|
||||
// JSON into somebody's conversation on every Android install that
|
||||
// predates it. The engine has had `isRenderable` since §6.15; the
|
||||
// app simply never consulted it.
|
||||
if (!message.content.isRenderable) continue
|
||||
|
||||
val gid = if (message.fromAddress != null) null else message.content.groupId
|
||||
if (gid != null) {
|
||||
val known = groupRoster(gid)
|
||||
@@ -1046,7 +1184,11 @@ class Session(
|
||||
// side.
|
||||
messageId = message.content.messageId,
|
||||
about = message.content.about,
|
||||
lifetimeSeconds = message.content.lifetimeSeconds ?: 0)
|
||||
lifetimeSeconds = message.content.lifetimeSeconds ?: 0,
|
||||
// §6.16 — so second-hand words are not drawn as the sender's
|
||||
// own. The engine has read `fwd` off the wire since forwarding
|
||||
// shipped; the transcript was where it stopped.
|
||||
forwarded = message.content.isForwarded)
|
||||
touched += thread
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user