From 62356139dc26a7095fdbda3480a5f121ffa0a944 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 06:33:22 -0400 Subject: [PATCH] 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 55 +++++++++++++++ .../messenger/app/MessengerService.kt | 2 +- .../messenger/app/MessengerViewModel.kt | 43 +++++++++--- .../io/flamenet/messenger/app/Session.kt | 67 +++++++++++++++++++ .../kotlin/io/flamenet/client/MessengerApi.kt | 5 ++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 37 ++++++++++ 6 files changed, 200 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index a39411d..9be03d3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -666,6 +667,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { ParentalControlsSection() } + SectionBand() + SettingsSection { ContactListPrivacySection(model) } + SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) } @@ -2413,6 +2417,57 @@ fun ColumnScope.AddContactForm( ) { Text("Add contact") } } +/** P1: where the contact list lives, and the door that moves it. */ +@Composable +fun ContactListPrivacySection(model: MessengerViewModel) { + var confirming by remember { mutableStateOf(false) } + val off = model.rosterIsOffServer() + + Text("Contact list", style = MaterialTheme.typography.titleMedium) + if (off) { + Text( + "Your contact list lives only on your devices — kept in this " + + "phone's encrypted store, and carried by your encrypted " + + "history export. The server no longer knows who your " + + "contacts are.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + } else { + Text( + "Today the server stores who your contacts are, because your " + + "online status is shown only to people on that list. Removing " + + "it keeps contacts in this phone's encrypted store instead — " + + "the honest cost is that buddies will see you as offline, and " + + "a new device gets your contacts from your encrypted history " + + "export rather than the server. Do this after your other " + + "devices have updated to this version.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + if (!confirming) { + OutlinedButton( + onClick = { confirming = true }, + modifier = Modifier.testTag("roster-forget"), + ) { Text("Take my contact list off the server") } + } else { + Text("The server's copy is deleted, and buddies will see you as " + + "offline from now on.", + fontSize = 12.sp, color = Palette.SecondaryText) + Spacer(Modifier.height(Space.xs)) + Row { + OutlinedButton( + onClick = { model.takeRosterOffServer(); confirming = false }, + modifier = Modifier.testTag("roster-forget-really"), + ) { Text("Really remove it", color = MaterialTheme.colorScheme.error) } + Spacer(Modifier.width(Space.sm)) + OutlinedButton(onClick = { confirming = false }) { Text("Cancel") } + } + } + } +} + /** * The account's registered devices — every one is a full mailbox and a prekey * pool on the relay, which caps them at three. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt index 3f6fbea..087d165 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerService.kt @@ -202,7 +202,7 @@ class MessengerService : Service() { private fun nameFor(userId: Long, s: Session): String { names[userId]?.let { return it } names = runCatching { - s.api.buddies().associate { it.userId to it.displayName.ifBlank { it.handle } } + s.roster().associate { it.userId to it.displayName.ifBlank { it.handle } } }.getOrDefault(names) // Falls back to something answerable rather than a bare number. return names[userId] ?: "Someone" diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 8d995da..5677e32 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -294,7 +294,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = MessengerApi( credential.relayBaseUrl, credential.username, credential.appPassword), ) - val fetchedBuddies = runCatching { session!!.api.buddies() } + val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.relay.listGroups() } stored to Phase.SignedIn( handle = credential.username, @@ -454,7 +454,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential), ) - val fetchedBuddies = runCatching { session!!.api.buddies() } + val fetchedBuddies = runCatching { session!!.roster() } val fetchedGroups = runCatching { session!!.relay.listGroups() } return Phase.SignedIn( handle = account.username, @@ -489,7 +489,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { fun refreshBuddies() = viewModelScope.launch { val current = session ?: return@launch val fetched = withContext(Dispatchers.IO) { - runCatching { current.api.buddies() } + runCatching { current.roster() } // Pin each contact's global address the first time we see them. // Done here rather than at send time because it needs the // handle, which the buddy list has and a user id does not — and @@ -512,8 +512,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(busy = true, error = null) } val added = withContext(Dispatchers.IO) { runCatching { - current.api.addBuddy(username) - current.api.buddies() + // Off-server roster (P1): the add is this device's to make — + // the relay only resolved the handle, and that lookup was + // transient. The resolved profile came from lookupMember. + if (current.rosterOffServer()) { + val found = signedIn()?.lookedUp + ?: throw IllegalStateException("Look the person up first.") + current.rosterAddLocal(found.userId, found.handle, found.displayName) + } else { + current.api.addBuddy(username) + } + current.roster() } } added.onSuccess { list -> update { it.copy(busy = false, buddies = list) } } @@ -721,17 +730,35 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // -- Contacts: the rest of the CRUD -------------------------------------- fun removeBuddy(userId: Long) = act { current -> - current.api.removeBuddy(userId) - current.api.buddies() + // The sealed copy first: in off-server mode it IS the roster, and in + // mirror mode a remove must not resurrect on the next fetch. + current.rosterRemoveLocal(userId) + if (!current.rosterOffServer()) current.api.removeBuddy(userId) + current.roster() }.let { } + /** P1's one-way door, from Settings. */ + fun takeRosterOffServer() = viewModelScope.launch { + val current = session ?: return@launch + update { it.copy(busy = true, error = null) } + val done = withContext(Dispatchers.IO) { runCatching { current.forgetRosterOnServer() } } + done.onSuccess { + update { it.copy(busy = false, + status = "Contact list removed from the server. It lives in this device's encrypted store now.") } + }.onFailure { failure -> + update { it.copy(busy = false, error = describe(failure)) } + } + } + + fun rosterIsOffServer(): Boolean = session?.rosterOffServer() ?: false + fun setBlocked(userId: Long, blocked: Boolean) = viewModelScope.launch { val current = session ?: return@launch update { it.copy(busy = true, error = null) } val done = withContext(Dispatchers.IO) { runCatching { current.api.setBlocked(userId, blocked) - current.api.blocked().toSet() to current.api.buddies() + current.api.blocked().toSet() to current.roster() } } done.onSuccess { (blockedIds, list) -> diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 57b7333..9f4dc4f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -712,6 +712,73 @@ class Session( return posted } + // -- Contact list (P1) --------------------------------------------------- + + /** + * The roster, from wherever it authoritatively lives. + * + * Off-server mode: the vault's sealed copy IS the list. Otherwise the + * relay's copy is fetched and mirrored into the vault — so the local copy + * is always ready to take over, rides the encrypted history export, and + * serves as the offline fallback when the fetch fails. + */ + fun roster(): List { + val (items, offServer) = vault.loadRoster() + fun stored() = (0 until items.length()).map { + val e = items.getJSONObject(it) + io.flamenet.client.MessengerApi.Buddy( + userId = e.optLong("id"), + displayName = e.optString("name", ""), + handle = e.optString("handle", ""), + presence = "offline", + ) + } + if (offServer) return stored() + return try { + val fetched = api.buddies() + val mirror = org.json.JSONArray() + fetched.forEach { b -> + mirror.put(org.json.JSONObject() + .put("id", b.userId).put("handle", b.handle).put("name", b.displayName)) + } + vault.saveRoster(mirror, offServer = false) + fetched + } catch (e: Exception) { + if (items.length() > 0) stored() else throw e + } + } + + fun rosterOffServer(): Boolean = vault.loadRoster().second + + fun rosterAddLocal(userId: Long, handle: String, name: String) { + val (items, offServer) = vault.loadRoster() + for (i in 0 until items.length()) { + if (items.getJSONObject(i).optLong("id") == userId) return + } + items.put(org.json.JSONObject().put("id", userId).put("handle", handle).put("name", name)) + vault.saveRoster(items, offServer) + } + + fun rosterRemoveLocal(userId: Long) { + val (items, offServer) = vault.loadRoster() + val kept = org.json.JSONArray() + for (i in 0 until items.length()) { + val e = items.getJSONObject(i) + if (e.optLong("id") != userId) kept.put(e) + } + vault.saveRoster(kept, offServer) + } + + /** The one-way door: the relay's copy is deleted, the vault's takes over. */ + fun forgetRosterOnServer() { + // Mirror FIRST — the door only closes once this device provably + // holds the list. + roster() + api.forgetRoster() + val (items, _) = vault.loadRoster() + vault.saveRoster(items, offServer = true) + } + fun markStorySeen(storyId: String) { val now = System.currentTimeMillis() / 1000.0 vault.saveStories( diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index 3dc69b8..9201cd1 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -104,6 +104,11 @@ class MessengerApi( delete("buddies/$userId") } + /** P1's one-way door: delete this account's contact rows, all of them. */ + fun forgetRoster() { + delete("buddies") + } + fun setStatus(status: String) { post("status", JSONObject().put("status", status)) } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 1498b2b..00cd118 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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 { + 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() + 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) }