Merge branch 'feat/per-message-timers' into 'main'

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

See merge request paulhitt/flamenet-messenger-android!15
This commit was merged in pull request #42.
This commit is contained in:
Paul Hitt
2026-09-02 19:59:51 +00:00
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"))
}
}