A timer on one message, not just the conversation (§6.17)

The rule that keeps this from making the product worse: the shorter
timer always wins. Without it a sender could defeat the recipient's
conversation timer by putting a long secs on every message — turning a
setting the recipient chose, on their own device, into something the
other party overrules.

🔴 pruneExpired returned early when the CONVERSATION had no timer, so a
timed message in an untimed thread would have been kept for ever — the
feature working on the sending phone and doing nothing on the receiving
one. Pruning is per message now.

⚠️ Content.text returns raw bytes with no envelope when it has nothing
to carry, so the timer had to join that decision or it would be dropped
and the message would live for ever.

The sender's own copy expires too: a timer that only cleared the
recipient's phone would leave the message with the person who set it.

Refs paulhitt/flamenet-messenger-ios#29

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 15:59:37 -04:00
co-authored by Claude Opus 4.8
parent 1fa70b5840
commit ddb82b87b6
5 changed files with 293 additions and 13 deletions
+1
View File
@@ -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 | | 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 | | 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 | | 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 | | 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 | ✅ 24h, sent per-contact, expired bytes deleted | | Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
@@ -94,6 +94,13 @@ class Session(
* to know anything had been dropped. * to know anything had been dropped.
*/ */
val about: Content.Reference? = null, 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. */ /** Where an outbound message got to. */
@@ -127,6 +134,7 @@ 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) },
lifetimeSeconds = m.optInt("secs", 0),
) )
} }
} }
@@ -145,10 +153,16 @@ class Session(
messageId: String? = null, messageId: String? = null,
/** What this message quotes, when it is a reply (§6.9). */ /** What this message quotes, when it is a reply (§6.9). */
about: Content.Reference? = null, about: Content.Reference? = null,
/** A timer this one message carries, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
) { ) {
vault.append(threadId, JSONObject() vault.append(threadId, JSONObject()
.put("author", author) .put("author", author)
.put("text", text).put("mine", mine).put("at", at) .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 (delivery != null) it.put("delivery", delivery.name) }
.also { .also {
if (sticker != null) { if (sticker != null) {
@@ -321,14 +335,20 @@ class Session(
* for every message that has no link in it. * for every message that has no link in it.
*/ */
link: Content.LinkPreview? = null, link: Content.LinkPreview? = null,
/** A timer for this one message, in seconds (§6.17); 0 for none. */
lifetimeSeconds: Int = 0,
): Conversations.Sent { ): Conversations.Sent {
// With no preview this is the raw body, byte for byte, exactly as // With no preview and no timer this is the raw body, byte for byte,
// before — `Content.text` only builds an envelope when it has to. // exactly as before — `Content.text` only builds an envelope when it
val plaintext = Content.text(text, link = link) // has to.
val plaintext = Content.text(text, link = link, lifetimeSeconds = lifetimeSeconds)
val sent = conversations.send(peerUserId, plaintext) val sent = conversations.send(peerUserId, plaintext)
val at = System.currentTimeMillis() 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, record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
link = link, link = link, lifetimeSeconds = lifetimeSeconds,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING) delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
// 🔴 Queued, not abandoned. A friend who is simply signed out should // 🔴 Queued, not abandoned. A friend who is simply signed out should
// not cost the sender a reminder to try again later. // 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) }, sticker = message.content.sticker?.let { Sticker(it.pack, it.id) },
author = message.fromUser, author = message.fromUser,
messageId = message.content.messageId, messageId = message.content.messageId,
about = message.content.about) about = message.content.about,
lifetimeSeconds = message.content.lifetimeSeconds ?: 0)
touched += thread touched += thread
} }
@@ -199,6 +199,26 @@ data class Content(
val isForwarded: Boolean val isForwarded: Boolean
get() = raw?.optBoolean("fwd", false) == true || get() = raw?.optBoolean("fwd", false) == true ||
(raw?.optInt("fwd", 0) ?: 0) != 0 (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. * A kind this build does not know, which is not control traffic.
@@ -339,15 +359,19 @@ data class Content(
about: Reference? = null, about: Reference? = null,
link: LinkPreview? = null, link: LinkPreview? = null,
forwarded: Boolean = false, forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
): String = ): 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 // 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 // raw bytes with no envelope at all — so a forward that was not
// counted here would lose its flag and arrive looking like the // counted here would lose its flag and arrive looking like the
// sender's own words. // sender's own words, and a timed message would lose its timer and
if (messageId == null && about == null && link == null && !forwarded) body // live for ever.
if (messageId == null && about == null && link == null && !forwarded
&& (lifetimeSeconds ?: 0) <= 0) body
else assemble("text", caption = body, messageId = messageId, about = about, else assemble("text", caption = body, messageId = messageId, about = about,
link = link, forwarded = forwarded) link = link, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds)
fun groupText( fun groupText(
body: String, body: String,
@@ -371,6 +395,7 @@ data class Content(
streamUrl: String? = null, streamUrl: String? = null,
groupName: String? = null, groupName: String? = null,
forwarded: Boolean = false, forwarded: Boolean = false,
lifetimeSeconds: Int? = null,
): String = assemble( ): String = assemble(
kind = ref.kind, kind = ref.kind,
caption = caption?.takeIf { it.isNotEmpty() }, caption = caption?.takeIf { it.isNotEmpty() },
@@ -382,6 +407,7 @@ data class Content(
streamUrl = streamUrl, streamUrl = streamUrl,
groupName = groupName, groupName = groupName,
forwarded = forwarded, forwarded = forwarded,
lifetimeSeconds = lifetimeSeconds,
) )
/** /**
@@ -444,11 +470,18 @@ data class Content(
groupName: String? = null, groupName: String? = null,
forwarded: Boolean = false, forwarded: Boolean = false,
mentions: List<Long> = emptyList(), mentions: List<Long> = emptyList(),
lifetimeSeconds: Int? = null,
): String { ): String {
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}") val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
// ⚠️ `1`, not `true` — §6.10's shape for every flag on this // ⚠️ `1`, not `true` — §6.10's shape for every flag on this
// envelope, and what the other two engines read first. // envelope, and what the other two engines read first.
if (forwarded) parts += "\"fwd\":1" 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. // §6.18 — omitted when there are none, which is nearly every message.
if (mentions.isNotEmpty()) { if (mentions.isNotEmpty()) {
parts += "\"ment\":[${mentions.joinToString(",")}]" parts += "\"ment\":[${mentions.joinToString(",")}]"
@@ -114,6 +114,28 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
*/ */
fun visibleStories(stories: List<Story>, muted: Set<Long>, me: Long): List<Story> = fun visibleStories(stories: List<Story>, muted: Set<Long>, me: Long): List<Story> =
stories.filter { it.authorId == me || it.authorId !in muted } 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 threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId" fun threadForGroup(groupId: String) = "group:$groupId"
@@ -1035,20 +1057,24 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* Returns the surviving messages. * Returns the surviving messages.
*/ */
fun pruneExpired(threadId: String, now: Long): org.json.JSONArray { fun pruneExpired(threadId: String, now: Long): org.json.JSONArray {
val seconds = disappearSeconds(threadId) val conversation = disappearSeconds(threadId)
val stored = readJson(historyName(threadId))?.optJSONArray("messages") val stored = readJson(historyName(threadId))?.optJSONArray("messages")
?: org.json.JSONArray() ?: 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() val kept = org.json.JSONArray()
for (i in 0 until stored.length()) { for (i in 0 until stored.length()) {
val m = stored.getJSONObject(i) 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 // A message with no timestamp predates the field. Kept rather than
// destroyed: an unknown age is not evidence of an old one, and // destroyed: an unknown age is not evidence of an old one, and
// deleting somebody's history on a guess is unrecoverable. // deleting somebody's history on a guess is unrecoverable.
val at = m.optLong("at", 0L) 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()) { if (kept.length() != stored.length()) {
writeJson(historyName(threadId), JSONObject().put("messages", kept)) writeJson(historyName(threadId), JSONObject().put("messages", kept))
@@ -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<String, ByteArray>()
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"))
}
}