From 2cf20e761a792a90e413e6e73b22507db45dbdfd Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 22 Sep 2026 07:27:49 -0400 Subject: [PATCH] Keep off-the-record unread marks out of the vault `MessengerViewModel.update` persists the unread set through `Vault.saveUnreadThreads` on every change, unfiltered. A thread id for a user conversation is `user:`, so an off-the-record conversation with something unread put the peer's id on disk, and it came back at the next launch. `saveOffTheRecord` says the setting is kept and only the setting. The filter goes in `saveUnreadThreads` rather than at the call site, for the reason the call site gives for being a chokepoint: a later caller cannot forget it. The vault already answers `offTheRecord(threadId)` for exactly that key, so the check was available and unused. Nothing is lost. An off-the-record thread holds no lines at rest, so a restored mark would badge a conversation with nothing in it. iOS had the same fault on `unreadPeers` and is fixed in ios!221. Both are the September pins-and-drafts finding again, on the one dictionary each side had left. Falsified: dropping the filter turns two of the four tests red, and the report XML says `tests="4" skipped="0"` in both directions, since gradle will report success having run nothing. Engine 711 and client 469, both green. The 55 engine failures a worktree shows first are `vectors not found`: the vector tests resolve the published vectors through a sibling `flamenet-e2e` checkout, which a worktree has no path to. Co-Authored-By: Claude Opus 5 --- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 16 +++- .../e2e/OffTheRecordUnreadMarksTest.kt | 94 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordUnreadMarksTest.kt diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 2893872..f1a975e 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -1381,9 +1381,23 @@ class Vault( return (0 until array.length()).map { array.optString(it) }.filter { it.isNotEmpty() }.toSet() } + /** + * Write the unread marks, minus any off-the-record thread. + * + * The filter is here rather than at the call site for the reason the call + * site gives for being a chokepoint: a later caller cannot forget it. A + * thread id for a user conversation is `user:`, so an unfiltered save + * put the peer's id on disk for a conversation §6.28 says is gone when the + * app closes, and [saveOffTheRecord] says keeps the setting and nothing + * else. + * + * Nothing is lost by dropping them. An off-the-record thread holds no + * lines at rest, so a restored mark would badge a conversation with + * nothing in it. + */ fun saveUnreadThreads(ids: Set) { val array = org.json.JSONArray() - ids.sorted().forEach { array.put(it) } + ids.filterNot { offTheRecord(it) }.sorted().forEach { array.put(it) } writeJson(UNREAD_THREADS, JSONObject().put("ids", array)) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordUnreadMarksTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordUnreadMarksTest.kt new file mode 100644 index 0000000..54d714c --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/OffTheRecordUnreadMarksTest.kt @@ -0,0 +1,94 @@ +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.assertTrue + +/** + * An unread mark for an off-the-record thread never reaches the vault. + * + * `saveOffTheRecord` says the setting is kept and only the setting. A thread + * id for a user conversation is `user:`, so saving the unread set + * unfiltered put the peer's id on disk for a conversation §6.28 says is gone + * when the app closes, and brought it back at the next launch. + * + * The same fault the pins had, on the set the view model persists at its one + * chokepoint. Being the only write site is what makes it the right place to + * filter, not a reason the filter was unnecessary. + */ +class OffTheRecordUnreadMarksTest { + 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() + + private val secret = "user:7" + private val ordinary = "user:8" + + @Test + fun `an off the record thread is left out of the saved marks`() { + val storage = MapStorage() + val k = key() + Vault(storage, k).apply { + saveOffTheRecord(secret, true) + saveUnreadThreads(setOf(secret, ordinary)) + } + + val restored = Vault(storage, k).unreadThreads() + assertFalse(secret in restored, "the off-the-record thread id was written to the vault") + assertTrue(ordinary in restored, "an ordinary conversation stopped being badged") + assertEquals(1, restored.size) + } + + @Test + fun `nothing is dropped when no thread is off the record`() { + val storage = MapStorage() + val k = key() + Vault(storage, k).saveUnreadThreads(setOf(secret, ordinary)) + assertEquals(setOf(secret, ordinary), Vault(storage, k).unreadThreads()) + } + + /** + * Turning it on does not retroactively remove a mark already written, but + * the next save must drop it: the set is rewritten whole every time. + */ + @Test + fun `the next save after turning it on clears the mark`() { + val storage = MapStorage() + val k = key() + val v = Vault(storage, k) + v.saveUnreadThreads(setOf(secret, ordinary)) + assertTrue(secret in Vault(storage, k).unreadThreads()) + + v.saveOffTheRecord(secret, true) + v.saveUnreadThreads(setOf(secret, ordinary)) + assertFalse(secret in Vault(storage, k).unreadThreads()) + } + + @Test + fun `a group thread has no off the record setting and passes through`() { + val storage = MapStorage() + val k = key() + Vault(storage, k).saveUnreadThreads(setOf("group:g-1")) + assertTrue("group:g-1" in Vault(storage, k).unreadThreads()) + } +}