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:<id>`, 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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-22 07:27:49 -04:00
co-authored by Claude Opus 5
parent 2c22ffed75
commit 2cf20e761a
2 changed files with 109 additions and 1 deletions
@@ -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:<id>`, 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<String>) {
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))
}
@@ -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:<id>`, 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<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()
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())
}
}