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