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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Long> = 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(",")}]"
|
||||
|
||||
@@ -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> =
|
||||
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))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user