diff --git a/PARITY.md b/PARITY.md index c444ae9..10346d7 100644 --- a/PARITY.md +++ b/PARITY.md @@ -99,6 +99,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Forwarding (§6.16) | 🟡 codec done, in progress — an inbound forward is recognised and shown as one; originating needs #10 | | Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id | | Mentions (§6.18) | 🟡 codec done, in progress — an inbound mention parses and is bounded; composing and notifying need the UI | +| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress | | 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 | diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 0adfd48..fd0e129 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -94,6 +94,13 @@ class Session( * to know anything had been dropped. */ val about: Content.Reference? = null, +/** + * A timer this one message carries, in seconds (§6.17); 0 for none. + * + * ⚠️ Not the conversation's — `Vault.effectiveLifetime` combines the + * two, and the shorter always wins. + */ + val lifetimeSeconds: Int = 0, ) /** Where an outbound message got to. */ @@ -127,6 +134,7 @@ 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) }, + lifetimeSeconds = m.optInt("secs", 0), ) } } @@ -145,10 +153,16 @@ class Session( messageId: String? = null, /** What this message quotes, when it is a reply (§6.9). */ about: Content.Reference? = null, + /** A timer this one message carries, in seconds (§6.17); 0 for none. */ + lifetimeSeconds: Int = 0, ) { vault.append(threadId, JSONObject() .put("author", author) .put("text", text).put("mine", mine).put("at", at) + // ⚠️ Written only when there is one. A zero on every message would + // grow the transcript for a feature almost nobody uses, and would + // make "no timer" indistinguishable from "a timer of nothing". + .also { if (lifetimeSeconds > 0) it.put("secs", lifetimeSeconds) } .also { if (delivery != null) it.put("delivery", delivery.name) } .also { if (sticker != null) { @@ -321,14 +335,20 @@ class Session( * for every message that has no link in it. */ link: Content.LinkPreview? = null, + /** A timer for this one message, in seconds (§6.17); 0 for none. */ + lifetimeSeconds: Int = 0, ): Conversations.Sent { - // With no preview this is the raw body, byte for byte, exactly as - // before — `Content.text` only builds an envelope when it has to. - val plaintext = Content.text(text, link = link) + // With no preview and no timer this is the raw body, byte for byte, + // exactly as before — `Content.text` only builds an envelope when it + // has to. + val plaintext = Content.text(text, link = link, lifetimeSeconds = lifetimeSeconds) val sent = conversations.send(peerUserId, plaintext) val at = System.currentTimeMillis() + // 🔴 The sender's own copy expires too. A timer that only cleared the + // recipient's phone would be the opposite of the promise: the person + // who set it would be the one still holding the message. record(Vault.threadForUser(peerUserId), text, mine = true, at = at, - link = link, + link = link, lifetimeSeconds = lifetimeSeconds, delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) // 🔴 Queued, not abandoned. A friend who is simply signed out should // not cost the sender a reminder to try again later. @@ -837,7 +857,8 @@ class Session( sticker = message.content.sticker?.let { Sticker(it.pack, it.id) }, author = message.fromUser, messageId = message.content.messageId, - about = message.content.about) + about = message.content.about, + lifetimeSeconds = message.content.lifetimeSeconds ?: 0) touched += thread } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index 56f6dd1..5ec00a0 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -199,6 +199,26 @@ data class Content( val isForwarded: Boolean get() = raw?.optBoolean("fwd", false) == true || (raw?.optInt("fwd", 0) ?: 0) != 0 +/** + * How long this one message lives, in seconds (§6.17), or null for no + * timer of its own. + * + * 🔴 **A duration, never a deadline.** An absolute expiry would be the + * sender's clock, and a sender who chose it could backdate a message so it + * vanished from the recipient's phone on arrival, or forward-date one so it + * never expired. The receiver counts from when IT recorded the message — + * the rule §6.11 already follows. + * + * ⚠️ Only on a message. A [Kind.DISAPPEAR] envelope carries `secs` too and + * means something entirely different: it sets the whole conversation. Both + * being read off one field is why this checks the kind. + */ + val lifetimeSeconds: Int? + get() { + if (kind == Kind.DISAPPEAR) return null + val secs = raw?.optInt("secs", 0) ?: 0 + return if (secs > 0) secs else null + } /** * A kind this build does not know, which is not control traffic. @@ -339,15 +359,19 @@ data class Content( about: Reference? = null, link: LinkPreview? = null, forwarded: Boolean = false, + lifetimeSeconds: Int? = null, ): String = - // ⚠️ `forwarded` belongs in this condition, not just in the + // ⚠️ Both of these belong in this condition, not just in the // envelope. A plain message with nothing else to carry goes out as // raw bytes with no envelope at all — so a forward that was not // counted here would lose its flag and arrive looking like the - // sender's own words. - if (messageId == null && about == null && link == null && !forwarded) body + // sender's own words, and a timed message would lose its timer and + // live for ever. + if (messageId == null && about == null && link == null && !forwarded + && (lifetimeSeconds ?: 0) <= 0) body else assemble("text", caption = body, messageId = messageId, about = about, - link = link, forwarded = forwarded) + link = link, forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds) fun groupText( body: String, @@ -371,6 +395,7 @@ data class Content( streamUrl: String? = null, groupName: String? = null, forwarded: Boolean = false, + lifetimeSeconds: Int? = null, ): String = assemble( kind = ref.kind, caption = caption?.takeIf { it.isNotEmpty() }, @@ -382,6 +407,7 @@ data class Content( streamUrl = streamUrl, groupName = groupName, forwarded = forwarded, + lifetimeSeconds = lifetimeSeconds, ) /** @@ -444,11 +470,18 @@ data class Content( groupName: String? = null, forwarded: Boolean = false, mentions: List = emptyList(), + lifetimeSeconds: Int? = null, ): String { val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") // ⚠️ `1`, not `true` — §6.10's shape for every flag on this // envelope, and what the other two engines read first. if (forwarded) parts += "\"fwd\":1" + // §6.17 — this one message's own timer. Reuses `secs`, the field + // the per-conversation announcement already uses, rather than + // inventing a second name for a number of seconds. + if (lifetimeSeconds != null && lifetimeSeconds > 0) { + parts += "\"secs\":$lifetimeSeconds" + } // §6.18 — omitted when there are none, which is nearly every message. if (mentions.isNotEmpty()) { parts += "\"ment\":[${mentions.joinToString(",")}]" diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 7684fc8..c409446 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -114,6 +114,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) { */ fun visibleStories(stories: List, muted: Set, me: Long): List = stories.filter { it.authorId == me || it.authorId !in muted } +/** + * How long a message actually lives, given its own timer and its + * conversation's (§6.17). Null means it does not expire. + * + * 🔴 **The shorter of the two always wins, and that is the whole + * rule.** A per-message timer may only take life away, never add it. + * Otherwise a sender could defeat the recipient's conversation timer by + * putting a long one on every message — turning a setting the recipient + * chose, on their own device, into something the other party overrules. + * + * ⚠️ Zero means "no timer", not "expire immediately", on both axes. + * Reading it the other way would delete a message the instant it + * arrived, and zero is how "off" has always been spelled here. + */ + fun effectiveLifetime(message: Int, conversation: Int): Int? { + val own = message.takeIf { it > 0 } + val thread = conversation.takeIf { it > 0 } + return when { + own != null && thread != null -> minOf(own, thread) + else -> own ?: thread + } + } fun threadForUser(userId: Long) = "user:$userId" fun threadForGroup(groupId: String) = "group:$groupId" @@ -1035,20 +1057,24 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * Returns the surviving messages. */ fun pruneExpired(threadId: String, now: Long): org.json.JSONArray { - val seconds = disappearSeconds(threadId) + val conversation = disappearSeconds(threadId) val stored = readJson(historyName(threadId))?.optJSONArray("messages") ?: org.json.JSONArray() - if (seconds <= 0 || stored.length() == 0) return stored + if (stored.length() == 0) return stored - val cutoff = now - seconds * 1000L val kept = org.json.JSONArray() for (i in 0 until stored.length()) { val m = stored.getJSONObject(i) + // ⚠️ Per MESSAGE, not per thread. A conversation with no timer can + // still hold messages carrying their own (§6.17), so the old + // `if (seconds <= 0) return` skipped the whole thread and silently + // kept every timed message in it. + val life = effectiveLifetime(m.optInt("secs", 0), conversation) // A message with no timestamp predates the field. Kept rather than // destroyed: an unknown age is not evidence of an old one, and // deleting somebody's history on a guess is unrecoverable. val at = m.optLong("at", 0L) - if (at == 0L || at >= cutoff) kept.put(m) + if (life == null || at == 0L || at >= now - life * 1000L) kept.put(m) } if (kept.length() != stored.length()) { writeJson(historyName(threadId), JSONObject().put("messages", kept)) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt new file mode 100644 index 0000000..29a67b9 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/PerMessageTimerTest.kt @@ -0,0 +1,199 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.json.JSONObject + +/** + * A timer on one message rather than the whole conversation (§6.17). + * + * 🔴 **The rule that keeps this from making the product worse is that the + * shorter timer always wins.** A per-message timer may only take life away, + * never add it. Without that, a sender could defeat the recipient's + * conversation timer by putting a long one on every message — turning a setting + * the recipient chose, on their own device, into something the other party gets + * to overrule. + */ +class PerMessageTimerTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + // ---------------------------------------------------------------- whose timer wins + + @Test + fun `a message timer applies when the conversation has none`() { + assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 0)) + } + + @Test + fun `the conversation timer applies when the message has none`() { + assertEquals(3600, Vault.effectiveLifetime(message = 0, conversation = 3600)) + } + + /** + * 🔴 The one that matters. A message asking to live a week, in a + * conversation set to an hour, lives an hour. + */ + @Test + fun `a longer message timer cannot extend a message's life`() { + assertEquals(3600, Vault.effectiveLifetime(message = 604_800, conversation = 3600), + "a sender overruled the recipient's own conversation timer") + } + + @Test + fun `a shorter message timer shortens it`() { + assertEquals(60, Vault.effectiveLifetime(message = 60, conversation = 3600)) + } + + @Test + fun `no timer anywhere means it does not expire`() { + assertNull(Vault.effectiveLifetime(message = 0, conversation = 0)) + } + + /** + * ⚠️ Zero means "no timer", not "expire immediately". Reading it the other + * way would delete a message the instant it arrived. + */ + @Test + fun `a negative timer is ignored rather than expiring immediately`() { + assertNull(Vault.effectiveLifetime(message = -1, conversation = 0)) + assertEquals(3600, Vault.effectiveLifetime(message = -1, conversation = 3600)) + } + + // ---------------------------------------------------------------- pruning + + private fun store(vault: Vault, threadId: String, at: Long, secs: Int, text: String) { + vault.append(threadId, JSONObject() + .put("text", text).put("mine", false).put("at", at) + .also { if (secs > 0) it.put("secs", secs) }) + } + + /** + * 🔴 **The bug the old code had.** Pruning returned early when the + * CONVERSATION had no timer, so a timed message in an untimed thread was + * kept for ever — the feature would have looked like it worked on the + * sending phone and done nothing on the receiving one. + */ + @Test + fun `a timed message expires even when the conversation has no timer`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + store(vault, thread, at = now - 120_000, secs = 60, text = "should be gone") + store(vault, thread, at = now, secs = 0, text = "should stay") + + val kept = vault.pruneExpired(thread, now) + assertEquals(1, kept.length()) + assertEquals("should stay", kept.getJSONObject(0).getString("text")) + } + + /** And the conversation timer still works on messages that carry none. */ + @Test + fun `the conversation timer still expires untimed messages`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + vault.saveDisappearSeconds(thread, 60) + store(vault, thread, at = now - 120_000, secs = 0, text = "old") + store(vault, thread, at = now, secs = 0, text = "new") + + val kept = vault.pruneExpired(thread, now) + assertEquals(1, kept.length()) + assertEquals("new", kept.getJSONObject(0).getString("text")) + } + + /** 🔴 A long message timer does not save a message from a short thread one. */ + @Test + fun `a long message timer does not survive a short conversation timer`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + val now = 1_000_000_000L + vault.saveDisappearSeconds(thread, 60) + store(vault, thread, at = now - 120_000, secs = 604_800, text = "asked to live a week") + + assertEquals(0, vault.pruneExpired(thread, now).length(), + "a sender overruled the recipient's conversation timer") + } + + /** + * ⚠️ A message with no timestamp predates the field. Kept rather than + * destroyed: an unknown age is not evidence of an old one, and deleting + * somebody's history on a guess is unrecoverable. + */ + @Test + fun `a message with no timestamp is kept`() { + val vault = Vault(MapStorage(), key()) + val thread = Vault.threadForUser(7) + vault.append(thread, JSONObject().put("text", "ancient").put("mine", false) + .put("secs", 60)) + + assertEquals(1, vault.pruneExpired(thread, 1_000_000_000L).length()) + } + + // ---------------------------------------------------------------- the wire + + /** + * 🔴 **A duration, never a deadline.** An absolute expiry would be the + * sender's clock: they could backdate a message so it vanished on arrival, + * or forward-date one so it never expired. + */ + @Test + fun `the envelope carries a duration not a deadline`() { + val wire = Content.text("meet at 6", lifetimeSeconds = 60) + assertTrue(wire.contains("\"secs\":60"), "wrong shape: $wire") + assertEquals(60, Content.parse(wire).lifetimeSeconds) + assertFalse(wire.contains("exp"), "the envelope carries a deadline: $wire") + } + + /** + * 🔴 **`Content.text` returns raw bytes when it has nothing to carry**, so a + * timer that was not counted in that decision would be dropped and the + * message would live for ever. + */ + @Test + fun `a timed message always gets an envelope`() { + val wire = Content.text("meet at 6", lifetimeSeconds = 60) + assertTrue(wire.startsWith("{\"fnc\":1,"), "the timer was dropped: $wire") + } + + @Test + fun `an untimed plain message still goes out as raw bytes`() { + assertEquals("meet at 6", Content.text("meet at 6")) + } + + /** + * ⚠️ A conversation-timer announcement carries `secs` too and means + * something entirely different. Reading both off one field is why this + * checks the kind. + */ + @Test + fun `a conversation timer announcement is not a timed message`() { + val announcement = Content.parse(Content.disappearing(3600)) + assertNull(announcement.lifetimeSeconds, + "a conversation timer was read as one message's own") + assertEquals(3600, Content.disappearSeconds(announcement)) + + val timed = Content.parse(Content.text("meet at 6", lifetimeSeconds = 60)) + assertNull(Content.disappearSeconds(timed), + "a timed message was read as setting the whole conversation") + } + + @Test + fun `a timer of zero is not written`() { + assertFalse(Content.text("hi", messageId = "m", lifetimeSeconds = 0).contains("secs")) + } +}