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
@@ -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.
@@ -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"
@@ -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) ->
@@ -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<io.flamenet.client.MessengerApi.Buddy> {
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(