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
@@ -94,15 +94,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
|||||||
| History at rest | ✅ encrypted, keyed by person not device |
|
| History at rest | ✅ encrypted, keyed by person not device |
|
||||||
| Session restore across restarts | ✅ credential and device both survive |
|
| Session restore across restarts | ✅ credential and device both survive |
|
||||||
| Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person |
|
| Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person |
|
||||||
| Edit a sent message (§6.15) | 🟡 codec back, and the `mid` it was blocked on now exists — applying an inbound edit and offering the control are UI work |
|
| Edit a sent message (§6.15) | 🟡 inbound edits now APPLY to the message they name; offering the control is UI work |
|
||||||
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
|
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
|
||||||
| Forwarding (§6.16) | 🟡 codec back; originating needs the UI |
|
| Forwarding (§6.16) | 🟡 `fwd` now reaches the transcript so second-hand words are not drawn as the sender's own; originating needs the UI |
|
||||||
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
|
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
|
||||||
| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text |
|
| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text |
|
||||||
| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress |
|
| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress |
|
||||||
| Search inside messages | ✅ scanned, never indexed |
|
| Search inside messages | ✅ scanned, never indexed |
|
||||||
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
|
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
|
||||||
| Pinned message (§6.19) | 🟡 codec back; the banner and the control are UI work |
|
| Pinned message (§6.19) | 🟡 inbound pins now stored per thread, sealed in the vault; the banner and the control are UI work |
|
||||||
| Photos | ✅ send via the system picker, render inline |
|
| Photos | ✅ send via the system picker, render inline |
|
||||||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||||||
| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted |
|
| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted |
|
||||||
@@ -222,6 +222,32 @@ somebody is grinding attempts. The steps are clamped before the shift, and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Inbound control envelopes
|
||||||
|
|
||||||
|
🔴 **The app never asked the engine whether an envelope belonged in a thread.**
|
||||||
|
`Content.isRenderable` has existed since §6.15 and `Session`'s inbound loop did
|
||||||
|
not consult 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;
|
||||||
|
- a **pin** carries only an id, so it drew as a blank bubble;
|
||||||
|
- and any **control envelope from a newer peer** would have drawn a bubble full
|
||||||
|
of JSON — the failure `isControl` was written to prevent, defeated by nobody
|
||||||
|
calling it.
|
||||||
|
|
||||||
|
All three now branch before filing, and the last is guarded by `isRenderable`
|
||||||
|
itself rather than by a list of kinds this build happens to know: an allowlist is
|
||||||
|
only correct until the other platform adds one.
|
||||||
|
|
||||||
|
⚠️ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test safety
|
## Test safety
|
||||||
|
|
||||||
| Feature | iOS | Android | Note |
|
| Feature | iOS | Android | Note |
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ class Session(
|
|||||||
* to know anything had been dropped.
|
* to know anything had been dropped.
|
||||||
*/
|
*/
|
||||||
val about: Content.Reference? = null,
|
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.
|
* 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) },
|
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
|
||||||
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
|
messageId = m.optString("mid").takeIf { it.isNotEmpty() },
|
||||||
about = m.optJSONObject("re")?.let { Content.Reference.fromJson(it) },
|
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),
|
lifetimeSeconds = m.optInt("secs", 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,6 +205,15 @@ class Session(
|
|||||||
about: Content.Reference? = null,
|
about: Content.Reference? = null,
|
||||||
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
|
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
|
||||||
lifetimeSeconds: Int = 0,
|
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()
|
vault.append(threadId, JSONObject()
|
||||||
.put("author", author)
|
.put("author", author)
|
||||||
@@ -201,10 +231,10 @@ class Session(
|
|||||||
}
|
}
|
||||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
||||||
.also { if (link != null) it.put("lnk", link.toJson()) }
|
.also { if (link != null) it.put("lnk", link.toJson()) }
|
||||||
// ⚠️ Written only when present. A null on every message would grow
|
// ⚠️ Written only when true, for the same reason `secs` is. A false
|
||||||
// the transcript for nothing and make "no id" indistinguishable
|
// on every message grows the transcript for a flag that is almost
|
||||||
// from "an id of nothing".
|
// always absent.
|
||||||
.also { if (messageId != null) it.put("mid", messageId) }
|
.also { if (forwarded) it.put("fwd", true) }
|
||||||
.also { if (about != null) it.put("re", about.toJson()) })
|
.also { if (about != null) it.put("re", about.toJson()) })
|
||||||
|
|
||||||
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||||
@@ -627,6 +657,70 @@ class Session(
|
|||||||
return hit
|
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].
|
* 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
|
if (story.isActive(now)) incomingStories += story
|
||||||
continue
|
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,
|
// 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
|
// never filed as one of its own — a thread where every thumbs-up
|
||||||
// is a bubble is a thread nobody can read.
|
// 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
|
// worse, match by coincidence. Groups do not federate yet, so a
|
||||||
// remote sender's group claim is ignored and the message files in
|
// remote sender's group claim is ignored and the message files in
|
||||||
// their own thread.
|
// 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
|
val gid = if (message.fromAddress != null) null else message.content.groupId
|
||||||
if (gid != null) {
|
if (gid != null) {
|
||||||
val known = groupRoster(gid)
|
val known = groupRoster(gid)
|
||||||
@@ -1046,7 +1184,11 @@ class Session(
|
|||||||
// side.
|
// side.
|
||||||
messageId = message.content.messageId,
|
messageId = message.content.messageId,
|
||||||
about = message.content.about,
|
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
|
touched += thread
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
|
import io.flamenet.e2e.Content
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits, pins and unknown control envelopes must never become bubbles
|
||||||
|
* (ios#6.15/#6.16/#6.19, Android half).
|
||||||
|
*
|
||||||
|
* 🔴 **The bug this closes 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 an edit from the iPhone app arrived carrying its new wording in `text` and
|
||||||
|
* was filed as an ORDINARY MESSAGE: a correction drawn as a duplicate beside the
|
||||||
|
* typo it was meant to replace. A pin was filed as a bubble whose text was
|
||||||
|
* empty. And any control envelope from a newer peer — which carries `ctl` and a
|
||||||
|
* kind this build has never heard of — would have drawn a bubble full of JSON
|
||||||
|
* in somebody's conversation.
|
||||||
|
*
|
||||||
|
* ⚠️ These test the ENGINE's classification, which is what the app now branches
|
||||||
|
* on. The app-side application (`applyEdit`, `applyPin`) needs a `Vault` and a
|
||||||
|
* keystore, so it is covered on device in `FeatureUseTest`; what is checkable
|
||||||
|
* here is the decision that used to be missing.
|
||||||
|
*/
|
||||||
|
class InboundControlTest {
|
||||||
|
|
||||||
|
private fun parse(json: String): Content = Content.parse(json)!!
|
||||||
|
|
||||||
|
// ---- What must not be drawn -----------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The case that shipped a duplicate bubble. An edit carries the new
|
||||||
|
* wording, so it looks exactly like a message to anything that does not ask.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an edit is not renderable, even though it carries text`() {
|
||||||
|
val edit = parse(Content.edit("the corrected wording", of = "mid-1"))
|
||||||
|
assertTrue(edit.editOf == "mid-1", "the codec no longer reads editOf")
|
||||||
|
assertTrue(edit.text?.isNotEmpty() == true, "an edit carries its new text")
|
||||||
|
assertFalse(edit.isRenderable, "an edit would be filed as a message")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pin names an id and has nothing to show. */
|
||||||
|
@Test
|
||||||
|
fun `a pin is not renderable`() {
|
||||||
|
val pin = parse(Content.pin("mid-1"))
|
||||||
|
assertTrue(pin.pins == "mid-1")
|
||||||
|
assertFalse(pin.isRenderable, "a pin would be filed as an empty bubble")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taking a pin down is the same envelope with an empty id. */
|
||||||
|
@Test
|
||||||
|
fun `taking a pin down is also not renderable`() {
|
||||||
|
val unpin = parse(Content.pin(""))
|
||||||
|
assertTrue(unpin.pins == "", "an empty id is how a pin is removed")
|
||||||
|
assertFalse(unpin.isRenderable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 **The case an allowlist cannot cover, and the reason the app branches
|
||||||
|
* on `isRenderable` rather than on a list of kinds it knows.** A control
|
||||||
|
* envelope from a NEWER peer has a kind nothing here has heard of. Keying on
|
||||||
|
* known kinds ships a bubble full of JSON to every older install the first
|
||||||
|
* time a new control message is added on the other platform.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a control envelope from a newer peer is not renderable`() {
|
||||||
|
val fromTheFuture = parse(
|
||||||
|
"""{"fnc":1,"kind":"something-invented-later","ctl":1,"text":"{}"}"""
|
||||||
|
)
|
||||||
|
assertTrue(fromTheFuture.isControl, "the ctl flag was not honoured")
|
||||||
|
assertFalse(fromTheFuture.isRenderable,
|
||||||
|
"an unknown control kind would be drawn as a message")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ Both shapes of the flag. `org.json`'s `optBoolean` does not coerce a
|
||||||
|
* number, so an engine sending `"ctl":1` rather than `"ctl":true` would slip
|
||||||
|
* past a reader that checked only one — which is how this class of bug got
|
||||||
|
* shipped once already.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the control flag is honoured as a number and as a boolean`() {
|
||||||
|
assertFalse(parse("""{"fnc":1,"kind":"x","ctl":1,"text":"{}"}""").isRenderable)
|
||||||
|
assertFalse(parse("""{"fnc":1,"kind":"x","ctl":true,"text":"{}"}""").isRenderable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- What must still be drawn ---------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The guard must not swallow ordinary messages. A `continue` in the wrong
|
||||||
|
* place is a conversation that silently stops receiving.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an ordinary message is still renderable`() {
|
||||||
|
val text = parse(Content.text("hello", messageId = "mid-1"))
|
||||||
|
assertTrue(text.isRenderable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reply is still renderable`() {
|
||||||
|
val reply = parse(
|
||||||
|
Content.text("answering", messageId = "mid-2",
|
||||||
|
about = Content.Reference("mid-1", 7L, "the question")),
|
||||||
|
)
|
||||||
|
assertTrue(reply.isRenderable)
|
||||||
|
assertTrue(reply.about?.messageId == "mid-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 **A forward IS a message.** It is second-hand words, not a control
|
||||||
|
* envelope — the flag changes how it is drawn, not whether it is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a forwarded message is renderable, and says so`() {
|
||||||
|
val forwarded = parse(
|
||||||
|
Content.text("passed along", messageId = "mid-3", forwarded = true),
|
||||||
|
)
|
||||||
|
assertTrue(forwarded.isRenderable, "a forward was treated as control")
|
||||||
|
assertTrue(forwarded.isForwarded, "the fwd flag was lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ And the same both-shapes rule for `fwd`, for the same reason. An engine
|
||||||
|
* writing `1` where this one writes `true` would have its forwards drawn as
|
||||||
|
* the sender's own words.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the forwarded flag is honoured as a number and as a boolean`() {
|
||||||
|
assertTrue(parse("""{"fnc":1,"kind":"text","fwd":1,"text":"x"}""").isForwarded)
|
||||||
|
assertTrue(parse("""{"fnc":1,"kind":"text","fwd":true,"text":"x"}""").isForwarded)
|
||||||
|
assertFalse(parse("""{"fnc":1,"kind":"text","text":"x"}""").isForwarded)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1658,7 +1658,36 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
* make the next message from that person undecryptable — a "clear chat"
|
* make the next message from that person undecryptable — a "clear chat"
|
||||||
* that quietly breaks the chat.
|
* that quietly breaks the chat.
|
||||||
*/
|
*/
|
||||||
fun forgetHistory(threadId: String) = storage.delete(historyName(threadId))
|
fun forgetHistory(threadId: String) {
|
||||||
|
storage.delete(historyName(threadId))
|
||||||
|
// ⚠️ The pin goes with the transcript. A banner pointing at a message
|
||||||
|
// that was just cleared is a reference to nothing, and it would survive
|
||||||
|
// "clear this conversation" — which reads as the clear not having
|
||||||
|
// worked.
|
||||||
|
storage.delete(pinnedName(threadId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pinned message (§6.19) ----------------------------------------------
|
||||||
|
|
||||||
|
private fun pinnedName(threadId: String) = "pinned-$threadId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message pinned in a thread, or null.
|
||||||
|
*
|
||||||
|
* 🔴 **Encrypted at rest like the transcript, not a preference.** Which
|
||||||
|
* message somebody pinned in which conversation is exactly the shape of
|
||||||
|
* metadata the vault exists to keep off a stolen phone — and it is stored
|
||||||
|
* as an ID, never a copy, so it cannot go stale against an edit or outlive
|
||||||
|
* a deletion.
|
||||||
|
*/
|
||||||
|
fun pinnedMessage(threadId: String): String? =
|
||||||
|
readJson(pinnedName(threadId))?.optString("mid")?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/** Pin a message, or clear the pin with null. */
|
||||||
|
fun savePinnedMessage(threadId: String, messageId: String?) {
|
||||||
|
if (messageId.isNullOrEmpty()) storage.delete(pinnedName(threadId))
|
||||||
|
else writeJson(pinnedName(threadId), JSONObject().put("mid", messageId))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete one line from a thread's history (delete for me).
|
* Delete one line from a thread's history (delete for me).
|
||||||
|
|||||||
Reference in New Issue
Block a user