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()) + } +}