P1: the contact list can live only on your devices

Session.roster() reads the vault once the list is off-server, mirrors
the relay's copy into the vault while one exists, and falls back to the
sealed copy when the network fails. The roster rides the history backup.
Settings gains the one-way door with the costs stated where the button
is: presence goes dark, and a new device restores contacts from the
encrypted backup rather than the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 06:33:22 -04:00
co-authored by Claude Opus 4.8
parent 2a5970309e
commit 62356139dc
6 changed files with 200 additions and 9 deletions
@@ -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 ROSTER = "roster"
/** Kept because the whole log is re-sealed on every write. */
private const val CALL_LOG_MAX = 500
@@ -572,6 +573,27 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
fun saveStoryAudience(audience: JSONObject) = writeJson(STORY_AUDIENCE, audience)
// -- Contact list (P1) ---------------------------------------------------
/**
* The contact list, once it lives here rather than on the relay.
*
* `offServer` is the one-way door: once true, adds and removes are
* local-only and the relay holds no copy. Kept in the vault like
* everything else — who somebody talks to is exactly the metadata this
* app exists to keep private — and exported/merged with the history
* backup, so a new device restores its contacts from the user's own
* backup rather than from a server-side graph.
*/
fun loadRoster(): Pair<org.json.JSONArray, Boolean> {
val json = readJson(ROSTER) ?: return org.json.JSONArray() to false
return (json.optJSONArray("items") ?: org.json.JSONArray()) to
json.optBoolean("offServer", false)
}
fun saveRoster(items: org.json.JSONArray, offServer: Boolean) =
writeJson(ROSTER, JSONObject().put("items", items).put("offServer", offServer))
/** Rewrite a thread wholesale, for restating a message's delivery state. */
fun replaceHistory(threadId: String, messages: JSONArray) =
writeJson(historyName(threadId), JSONObject().put("messages", messages))
@@ -594,6 +616,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
.put("threads", threadsObj)
.put("callLog", callLog())
.also { p -> readJson(STORIES)?.let { p.put("stories", it) } }
.also { p -> readJson(ROSTER)?.let { p.put("roster", it) } }
}
data class ImportCounts(val messages: Int, val stories: Int)
@@ -674,6 +697,20 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
}
}
// The contact list rides the backup too (P1): union by id, and the
// off-server mode is adopted if the backup carries it.
payload.optJSONObject("roster")?.let { imported ->
val (current, offServer) = loadRoster()
val known = HashSet<Long>()
for (i in 0 until current.length()) known.add(current.getJSONObject(i).optLong("id"))
val items = imported.optJSONArray("items") ?: org.json.JSONArray()
for (i in 0 until items.length()) {
val e = items.optJSONObject(i) ?: continue
if (known.add(e.optLong("id"))) current.put(e)
}
saveRoster(current, offServer || imported.optBoolean("offServer", false))
}
return ImportCounts(newMessages, newStories)
}