Disappearing messages, symmetric and from send time (§6.11)
The relay has had per-conversation lifetimes since the port and **no client could set one** — its own comment says so. For E2E conversations the server timer would not have helped anyway: it expires rows in the legacy plaintext table, not the envelopes these apps actually exchange. So this is client-side, which is also the only place it can be honest. **Symmetric.** Either party sets the timer; the other is told inside the ratchet and adopts it. A timer only the setter honoured would be worse than none — they would believe their words were gone from a phone still holding them. **Inside the ratchet**, so the relay never learns that a conversation is timed, let alone for how long. It rides as ordinary control traffic carrying the `ctl` flag from §6.10, which is what makes it safe to add at all: without that, this envelope would surface in every older install as an "[unsupported attachment]" bubble. **Expiry runs on read**, in `Vault.history`. A sweep on a timer leaves expired messages on disk between runs, and a disappearing message still sitting in the vault is a claim rather than a feature. Reading is the one path every caller shares, so nothing can see — or keep — an expired message. ⚠️ **Ages come from this device's own timestamp**, not one the peer supplies. "From send time" in practice means when this device sent or received it, which differs by seconds and cannot be manipulated: a peer who chose the clock could backdate a message so it vanished from your phone immediately, or forward-date one so it never expired. ⚠️ **A message with no timestamp is kept.** Those predate the field, and an unknown age is not evidence of an old one — deleting somebody's history on a guess cannot be undone. The panel sits on the thread rather than in a menu, because "fewer taps to turn it on" and "clearer per-conversation state" are the same problem: a timer nobody can see is one nobody trusts. Presets only — arbitrary durations are the ones nobody can reason about later. engine 155, client 94, app 32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c9e386ec0d
commit
f9f063a211
@@ -1369,6 +1369,11 @@ fun ColumnScope.ChatScreen(
|
||||
// identities and no single answer, so this is for one-to-one threads only.
|
||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
||||
|
||||
// Directly on the thread, for the same reason as the identity panel: both
|
||||
// are facts about this conversation, and a fact the user has to go looking
|
||||
// for is one they will not rely on.
|
||||
if (chat.peerId != null) DisappearingPanel(model, chat)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
|
||||
@@ -2598,3 +2603,71 @@ fun RegisteredDevicesSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The disappearing-message timer for one conversation (§6.11).
|
||||
*
|
||||
* 🔴 **On the thread, not in a menu.** The request was "fewer taps to turn on
|
||||
* and clearer per-conversation state", and both halves are the same problem: a
|
||||
* timer nobody can see is a timer nobody trusts. This shows what is set and
|
||||
* changes it in one tap.
|
||||
*
|
||||
* ⚠️ Both sides delete. The peer is told inside the ratchet and adopts the same
|
||||
* timer, so the copy says "for both of you" — a timer only the setter honoured
|
||||
* would be worse than none, because they would believe their words were gone
|
||||
* from a device still holding them.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.DisappearingPanel(
|
||||
model: MessengerViewModel,
|
||||
chat: MessengerViewModel.Phase.Chat,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) {
|
||||
Text("Disappearing messages", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Space.xs),
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DISAPPEAR_CHOICES.forEach { (label, seconds) ->
|
||||
KitChip(
|
||||
text = label,
|
||||
selected = chat.disappearSeconds == seconds,
|
||||
onClick = { model.setDisappearing(seconds) },
|
||||
tag = "disappear-$seconds",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
if (chat.disappearSeconds == 0) {
|
||||
"Messages stay until one of you deletes them."
|
||||
} else {
|
||||
"New and existing messages disappear from BOTH phones " +
|
||||
"${DISAPPEAR_LABELS[chat.disappearSeconds] ?: "after the timer"} " +
|
||||
"after they are sent. The relay is never told this is set."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = Space.xs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Presets, not a free-form duration. Every extra option is a decision the
|
||||
* user has to make about something they mostly want to be roughly right, and
|
||||
* the arbitrary values are the ones nobody can reason about later.
|
||||
*/
|
||||
private val DISAPPEAR_CHOICES = listOf(
|
||||
"Off" to 0,
|
||||
"1 hour" to 3_600,
|
||||
"1 day" to 86_400,
|
||||
"1 week" to 604_800,
|
||||
)
|
||||
|
||||
private val DISAPPEAR_LABELS = mapOf(
|
||||
3_600 to "an hour",
|
||||
86_400 to "a day",
|
||||
604_800 to "a week",
|
||||
)
|
||||
|
||||
@@ -205,6 +205,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val trust: IdentityTrust = IdentityTrust.UNKNOWN,
|
||||
/** The number both people read aloud. Null until something is pinned. */
|
||||
val safetyNumber: String? = null,
|
||||
/**
|
||||
* This conversation's disappearing-message timer, seconds, 0 = off.
|
||||
*
|
||||
* ⚠️ Shown on the thread rather than buried in a menu. A timer the
|
||||
* user cannot see is one they cannot trust — and both sides of the
|
||||
* conversation are deleting on it, so it is a fact about the room,
|
||||
* not a preference.
|
||||
*/
|
||||
val disappearSeconds: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -553,6 +562,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
thread, peerName, current.history(thread), peerId = peerId,
|
||||
trust = current.conversations.trust.state(peerId),
|
||||
safetyNumber = current.conversations.trust.safetyNumber(peerId),
|
||||
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
||||
),
|
||||
// Opening the thread is what marks it read. Nothing is sent to
|
||||
// the relay for this — read state is local, because a read
|
||||
@@ -792,6 +802,35 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Disappearing messages (§6.11) ---------------------------------------
|
||||
|
||||
/**
|
||||
* Set the timer for the open conversation, on both sides.
|
||||
*
|
||||
* ⚠️ The peer is told inside the ratchet and adopts it. That is what makes
|
||||
* this worth offering: a timer only this device honoured would leave the
|
||||
* user believing their words were gone from a phone still holding them.
|
||||
*/
|
||||
fun setDisappearing(seconds: Int) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
val peerId = chat.peerId ?: return@launch
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching { current.conversations.setDisappearing(peerId, seconds) }
|
||||
}
|
||||
update { state ->
|
||||
state.copy(
|
||||
openChat = state.openChat?.copy(
|
||||
disappearSeconds = current.conversations.disappearSeconds(peerId),
|
||||
// Applying the timer expires anything already past it, so
|
||||
// the thread has to be re-read rather than left on screen.
|
||||
lines = current.history(chat.threadId),
|
||||
),
|
||||
error = done.exceptionOrNull()?.let(::describe),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Presence ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -566,6 +566,31 @@ class Conversations(
|
||||
return Sent(delivered, failed, sealed = peerKey != null)
|
||||
}
|
||||
|
||||
// -- Disappearing messages (§6.11) ---------------------------------------
|
||||
|
||||
/**
|
||||
* Set this conversation's timer, and tell the other side.
|
||||
*
|
||||
* 🔴 **Symmetric by design.** Either party may set it and both delete, so
|
||||
* the announcement rides the ratchet as ordinary control traffic — the
|
||||
* relay never learns that a conversation is timed, let alone for how long.
|
||||
* A timer only one side honoured would be worse than none: the person who
|
||||
* turned it on would believe their words were gone from a device still
|
||||
* holding them.
|
||||
*
|
||||
* ⚠️ Stored locally **first**. If the send fails, this device still expires
|
||||
* its own copy — a half-applied timer that deletes too much is recoverable
|
||||
* by asking again; one that deletes too little is a broken promise.
|
||||
*/
|
||||
fun setDisappearing(toUserId: Long, seconds: Int) {
|
||||
vault.saveDisappearSeconds(Vault.threadForUser(toUserId), seconds)
|
||||
sendPlaintext(toUserId, Content.disappearing(seconds))
|
||||
}
|
||||
|
||||
/** This conversation's timer in seconds, 0 when off. */
|
||||
fun disappearSeconds(toUserId: Long): Int =
|
||||
vault.disappearSeconds(Vault.threadForUser(toUserId))
|
||||
|
||||
/**
|
||||
* Read the inbox, decrypt what we can, and advance the watermark.
|
||||
*
|
||||
@@ -795,7 +820,12 @@ class Conversations(
|
||||
}
|
||||
|
||||
/** Take in the control messages this layer owns, rather than showing them. */
|
||||
private fun consumeControl(fromUser: Long, content: Content) {
|
||||
/**
|
||||
* ⚠️ `internal`, not private: this is the seam where a control envelope
|
||||
* becomes a stored fact, and it is where the wiring is worth asserting
|
||||
* directly rather than through a full ratchet round trip.
|
||||
*/
|
||||
internal fun consumeControl(fromUser: Long, content: Content) {
|
||||
when (content.kind) {
|
||||
Content.Kind.DELIVERY_KEY -> {
|
||||
val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return
|
||||
@@ -818,6 +848,13 @@ class Conversations(
|
||||
val raw = content.raw ?: return
|
||||
trust.gossip(raw.optInt("size"), raw.optString("root"))
|
||||
}
|
||||
// The other side set a timer (§6.11). Adopted rather than offered:
|
||||
// a timer both parties have to agree to separately is a timer that
|
||||
// is off whenever one of them has not noticed the prompt.
|
||||
Content.Kind.DISAPPEAR -> {
|
||||
val seconds = Content.disappearSeconds(content) ?: return
|
||||
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import javax.crypto.KeyGenerator
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* The seam where a peer's timer announcement becomes a stored fact (§6.11).
|
||||
*
|
||||
* 🔴 **Symmetric or worthless.** If only the party who set the timer expires
|
||||
* their copy, the feature is a lie: they believe their words are gone from a
|
||||
* phone that is still holding them. So a `ttl` envelope from the other side has
|
||||
* to be adopted, not merely offered — which is what this asserts.
|
||||
*/
|
||||
class DisappearingControlTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||
@AfterTest fun stop() { server.shutdown() }
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<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 conversations(): Pair<Conversations, Vault> {
|
||||
val identity = DeviceIdentity.create()
|
||||
val vault = Vault(
|
||||
MapStorage(),
|
||||
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
|
||||
).also { it.saveIdentity(identity) }
|
||||
val relay = RelayClient(server.url("/").toString(), "alice", "app pass word")
|
||||
return Conversations(identity, vault, relay) to vault
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timer the peer set is adopted`() {
|
||||
val (conversations, vault) = conversations()
|
||||
conversations.consumeControl(42L, Content.parse(Content.disappearing(86_400)))
|
||||
assertEquals(86_400, vault.disappearSeconds(Vault.threadForUser(42L)))
|
||||
assertEquals(86_400, conversations.disappearSeconds(42L))
|
||||
}
|
||||
|
||||
/** Turning it off has to travel too, or it can only ever be tightened. */
|
||||
@Test
|
||||
fun `a peer turning the timer off is adopted`() {
|
||||
val (conversations, vault) = conversations()
|
||||
conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600)))
|
||||
conversations.consumeControl(42L, Content.parse(Content.disappearing(0)))
|
||||
assertEquals(0, vault.disappearSeconds(Vault.threadForUser(42L)))
|
||||
}
|
||||
|
||||
/** ⚠️ Per conversation. One contact's timer must not touch another's. */
|
||||
@Test
|
||||
fun `the timer applies only to the conversation it came from`() {
|
||||
val (conversations, _) = conversations()
|
||||
conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600)))
|
||||
assertEquals(3_600, conversations.disappearSeconds(42L))
|
||||
assertEquals(0, conversations.disappearSeconds(99L))
|
||||
}
|
||||
|
||||
/** The announcement is control traffic and never lands in the thread. */
|
||||
@Test
|
||||
fun `the announcement is never shown as a message`() {
|
||||
val parsed = Content.parse(Content.disappearing(60))
|
||||
assertEquals(false, parsed.isRenderable)
|
||||
}
|
||||
}
|
||||
@@ -191,12 +191,21 @@ data class Content(
|
||||
*/
|
||||
const val AVATAR = "avatar"
|
||||
|
||||
/**
|
||||
* A per-conversation disappearing-message timer, in seconds (§6.11).
|
||||
*
|
||||
* Announced inside the ratchet so the relay never learns that a
|
||||
* conversation is timed, let alone for how long. Carries `ctl` because
|
||||
* it postdates the original four control kinds.
|
||||
*/
|
||||
const val DISAPPEAR = "ttl"
|
||||
|
||||
/**
|
||||
* ⚠️ Consumed, never shown. Each of these is a real message the web
|
||||
* client sends today, and rendering one puts raw JSON in somebody's
|
||||
* chat.
|
||||
*/
|
||||
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR)
|
||||
val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR)
|
||||
|
||||
/**
|
||||
* Every kind this build can act on.
|
||||
@@ -306,6 +315,21 @@ data class Content(
|
||||
fun avatar(ref: Attachment.Ref): String =
|
||||
assemble(Kind.AVATAR, attachment = ref)
|
||||
|
||||
/**
|
||||
* Announce this conversation's disappearing-message timer (§6.11).
|
||||
*
|
||||
* `seconds` is 0 to turn it off. Hand-assembled like everything else
|
||||
* here so `fnc` stays the first key — the marker is a prefix test, and
|
||||
* a reordered top level silently becomes a plain-text message showing
|
||||
* as raw JSON in somebody's chat.
|
||||
*/
|
||||
fun disappearing(seconds: Int): String =
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":true,\"secs\":$seconds}"
|
||||
|
||||
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
|
||||
fun disappearSeconds(content: Content): Int? =
|
||||
if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
|
||||
|
||||
/** JSON string escaping, via the library, so control characters survive. */
|
||||
private fun quote(value: String): String = JSONObject.quote(value)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val OUTBOX = "outbox"
|
||||
private const val CALL_LOG = "call-log"
|
||||
private const val STORY_AUDIENCE = "story-audience"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val ROSTER = "roster"
|
||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||
private const val KT_ISSUER_PIN = "kt-issuer-pin"
|
||||
@@ -512,8 +513,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
* this to matter, the fix is an append-only record format, not a bigger
|
||||
* buffer.
|
||||
*/
|
||||
fun history(threadId: String): org.json.JSONArray =
|
||||
readJson(historyName(threadId))?.optJSONArray("messages") ?: org.json.JSONArray()
|
||||
/**
|
||||
* A thread, with anything past its disappearing-message timer already gone.
|
||||
*
|
||||
* 🔴 **Expiry happens here rather than on a schedule.** Reading is the one
|
||||
* path every caller shares, so pruning here is what makes it impossible to
|
||||
* see — or keep — an expired message. A sweep that ran on a timer would
|
||||
* leave the data on disk between runs, and "disappearing" that leaves the
|
||||
* data in the vault is a claim, not a feature.
|
||||
*
|
||||
* `now` is injectable so the behaviour can be tested without sleeping.
|
||||
*/
|
||||
fun history(threadId: String, now: Long = System.currentTimeMillis()): org.json.JSONArray =
|
||||
pruneExpired(threadId, now)
|
||||
|
||||
fun append(threadId: String, message: JSONObject) {
|
||||
val existing = history(threadId)
|
||||
@@ -521,6 +533,62 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", existing))
|
||||
}
|
||||
|
||||
// -- Disappearing messages (§6.11) ---------------------------------------
|
||||
|
||||
/**
|
||||
* This conversation's timer in seconds, 0 when off.
|
||||
*
|
||||
* ⚠️ In the vault, like everything else. A per-conversation timer is a
|
||||
* statement about how sensitive a relationship is, which is precisely the
|
||||
* metadata this app keeps off the server.
|
||||
*/
|
||||
fun disappearSeconds(threadId: String): Int =
|
||||
readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
|
||||
|
||||
fun saveDisappearSeconds(threadId: String, seconds: Int) {
|
||||
val all = readJson(DISAPPEAR) ?: JSONObject()
|
||||
if (seconds <= 0) all.remove(threadId) else all.put(threadId, seconds)
|
||||
writeJson(DISAPPEAR, all)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop everything in this thread older than its timer, and persist that.
|
||||
*
|
||||
* 🔴 **Called from [history], so nothing can read past it.** Expiry that
|
||||
* runs on a timer, or only when a screen opens, leaves the messages on disk
|
||||
* — and "disappearing" that leaves the data sitting in the vault is a claim
|
||||
* rather than a feature. Reading is the one path every caller shares.
|
||||
*
|
||||
* ⚠️ Ages are measured from the timestamp this device recorded, which for a
|
||||
* received message is when it arrived and for a sent one is when it was
|
||||
* sent. Deliberately not a clock value the peer supplies: a sender could
|
||||
* otherwise backdate a message so it vanished from the recipient's phone
|
||||
* immediately, or forward-date one so it never expired at all.
|
||||
*
|
||||
* Returns the surviving messages.
|
||||
*/
|
||||
fun pruneExpired(threadId: String, now: Long): org.json.JSONArray {
|
||||
val seconds = disappearSeconds(threadId)
|
||||
val stored = readJson(historyName(threadId))?.optJSONArray("messages")
|
||||
?: org.json.JSONArray()
|
||||
if (seconds <= 0 || 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)
|
||||
// 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 (kept.length() != stored.length()) {
|
||||
writeJson(historyName(threadId), JSONObject().put("messages", kept))
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages typed but not yet delivered to anybody.
|
||||
*
|
||||
|
||||
@@ -178,6 +178,45 @@ class ContentTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disappearing-message timers (§6.11).
|
||||
*
|
||||
* 🔴 The timer travels **inside the ratchet**. Sent any other way the relay
|
||||
* would learn that a conversation is timed and for how long — metadata this
|
||||
* feature exists to avoid producing, on a product whose claim is that the
|
||||
* server knows as little as possible.
|
||||
*/
|
||||
@Test
|
||||
fun `a disappearing timer round-trips and is control traffic`() {
|
||||
val parsed = Content.parse(Content.disappearing(86_400))
|
||||
assertEquals(Content.Kind.DISAPPEAR, parsed.kind)
|
||||
assertEquals(86_400, Content.disappearSeconds(parsed))
|
||||
assertTrue(parsed.isControl, "the timer is never shown as a message")
|
||||
assertFalse(parsed.isRenderable)
|
||||
}
|
||||
|
||||
/** Zero is how the timer is turned off, and must survive as zero. */
|
||||
@Test
|
||||
fun `turning the timer off round-trips as zero`() {
|
||||
val parsed = Content.parse(Content.disappearing(0))
|
||||
assertEquals(0, Content.disappearSeconds(parsed))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `fnc` must stay the first key. The marker is a prefix test, so a
|
||||
* reordered top level makes the envelope decode as a plain text message —
|
||||
* raw JSON in somebody's conversation.
|
||||
*/
|
||||
@Test
|
||||
fun `the timer envelope starts with the marker`() {
|
||||
assertTrue(Content.disappearing(60).startsWith("""{"fnc":1,"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `asking a non-timer envelope for its timer gives nothing`() {
|
||||
assertNull(Content.disappearSeconds(Content.parse(Content.text("hello"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
||||
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Disappearing messages (§6.11), at the vault.
|
||||
*
|
||||
* 🔴 **Expiry runs on read, and that is the point.** A sweep on a timer leaves
|
||||
* expired messages on disk between runs, and a "disappearing" message still
|
||||
* sitting in the vault is a claim rather than a feature. Reading is the one
|
||||
* path every caller shares.
|
||||
*/
|
||||
class DisappearingTest {
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<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 vault() = Vault(
|
||||
MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
|
||||
|
||||
private fun line(text: String, at: Long) =
|
||||
JSONObject().put("text", text).put("mine", true).put("at", at)
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
@Test
|
||||
fun `with no timer nothing is removed`() {
|
||||
val v = vault()
|
||||
v.append("t", line("ancient", 1L))
|
||||
assertEquals(1, v.history("t", now).length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `messages older than the timer are gone on the next read`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("t", 60)
|
||||
v.append("t", line("old", now - 120_000))
|
||||
v.append("t", line("fresh", now - 10_000))
|
||||
|
||||
val kept = v.history("t", now)
|
||||
assertEquals(1, kept.length())
|
||||
assertEquals("fresh", kept.getJSONObject(0).optString("text"))
|
||||
}
|
||||
|
||||
/** ⚠️ Gone from disk, not merely filtered out of the answer. */
|
||||
@Test
|
||||
fun `expiry is a deletion, not a filter`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("t", 60)
|
||||
v.append("t", line("old", now - 120_000))
|
||||
v.history("t", now)
|
||||
|
||||
// Read again with the timer switched off: an expired message must not
|
||||
// come back, which it would if the first read had only hidden it.
|
||||
v.saveDisappearSeconds("t", 0)
|
||||
assertEquals(0, v.history("t", now).length())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A message with no timestamp predates the field. An unknown age is not
|
||||
* evidence of an old one, and deleting somebody's history on a guess cannot
|
||||
* be undone.
|
||||
*/
|
||||
@Test
|
||||
fun `a message with no timestamp is kept`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("t", 60)
|
||||
v.append("t", JSONObject().put("text", "from before").put("mine", true))
|
||||
assertEquals(1, v.history("t", now).length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the timer is per conversation`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("timed", 60)
|
||||
v.append("timed", line("old", now - 120_000))
|
||||
v.append("untimed", line("old", now - 120_000))
|
||||
|
||||
assertEquals(0, v.history("timed", now).length())
|
||||
assertEquals(1, v.history("untimed", now).length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turning the timer off forgets it`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("t", 3600)
|
||||
assertEquals(3600, v.disappearSeconds("t"))
|
||||
v.saveDisappearSeconds("t", 0)
|
||||
assertEquals(0, v.disappearSeconds("t"))
|
||||
}
|
||||
|
||||
/** Exactly at the boundary the message survives — off-by-one deletes data. */
|
||||
@Test
|
||||
fun `a message exactly at the cutoff survives`() {
|
||||
val v = vault()
|
||||
v.saveDisappearSeconds("t", 60)
|
||||
v.append("t", line("edge", now - 60_000))
|
||||
assertTrue(v.history("t", now).length() == 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user