Signing out no longer costs every contact a prompt

`signOut` destroyed the vault unconditionally, so signing back in was a
genuinely NEW device: every contact had the old key pinned, saw a
safety-number change, and could not message you until they accepted it.
For someone stepping away for an afternoon that is a strange price, and
it is why signing out and back in kept looking like the app breaking.

Two doors now, matching iOS. Plain sign-out ends the session and keeps
the device, so coming back is silent. "Sign out and erase this device"
is its own row, confirms in place like the delete control beside it, and
retires the device on the relay BEFORE destroying the keys that
authorise saying so — otherwise the account goes on publishing a mailbox
nobody reads, while the relay reports every copy delivered.

Also: changing your password erased the phone. `updatePassword` signed
out to drop the stale credential, which is right, and took the
destructive door to do it, which was not — a password change made you a
new device to everyone you had ever spoken to. It takes the plain one
now. Account deletion asks for the erasing door explicitly.

⚠️ Keeping the keys does not leave the mailbox reachable: a relay token
is minted against the account credential, which both doors clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-01 14:08:42 -04:00
co-authored by Claude Opus 4.8
parent cb82d59b77
commit ccdf82c00a
9 changed files with 146 additions and 26 deletions
@@ -712,6 +712,7 @@ fun ColumnScope.AccountScreen(
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmingDelete by remember { mutableStateOf(false) }
var confirmingErase by remember { mutableStateOf(false) }
Text("Account", style = MaterialTheme.typography.headlineMedium)
Text(
@@ -876,9 +877,28 @@ fun ColumnScope.AccountScreen(
SettingsSection {
SettingsRow(
label = "Sign out",
description = "Erases this phone's keys and stored messages. They cannot be recovered.",
description = "Ends this session and keeps your messages on this phone, "
+ "so signing back in picks up where you left off.",
tag = "sign-out",
onClick = model::signOut,
onClick = { model.signOut() },
)
// 🔴 **The one that erases is its own row, and is not the default.**
// Both used to be the same button: signing out for an afternoon
// destroyed this phone's identity, so coming back made every contact
// accept a safety-number change first. Erasing is a real thing people
// need — handing a phone on — but it is the rarer thing, and it must be
// chosen rather than stumbled into. Confirms in place, like the pair
// below it, because a dialog is dismissed reflexively.
SettingsRow(
label = if (confirmingErase) "Really erase — this cannot be undone"
else "Sign out and erase this device",
description = "Also removes this phone's keys and stored messages. "
+ "Choose this if you are handing the phone on.",
tag = "sign-out-erase",
onClick = {
if (confirmingErase) model.signOut(erasingDevice = true)
else confirmingErase = true
},
)
SettingsRow(
label = if (confirmingDelete) "Really delete — this cannot be undone"
@@ -1392,7 +1392,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun deleteAccount() = viewModelScope.launch {
val current = session ?: return@launch
val done = withContext(Dispatchers.IO) { runCatching { current.api.deleteAccount() } }
done.onSuccess { signOut() }
done.onSuccess { signOut(erasingDevice = true) }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
@@ -2454,13 +2454,45 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
+ "new device.")
}
fun signOut() = viewModelScope.launch {
/**
* End the session, and optionally erase this phone with it.
*
* 🔴 **Signing out used to cost every one of your contacts a prompt.** It
* destroyed the vault unconditionally, so signing back in was a genuinely
* NEW device: every contact had the old key pinned, saw a safety-number
* change, and could not message you until they accepted it. For someone
* stepping away for an afternoon that is a strange price to pay, and it is
* why signing out and back in kept looking like the app breaking.
*
* Two doors now. Plain sign-out ends the session and keeps the device, so
* coming back is silent. Erasing is for handing the phone on, and for
* deleting the account, and the settings screen says which is which.
*
* ⚠️ Keeping the keys does not leave the mailbox reachable: a relay token
* is minted against the account credential, which this clears either way.
*/
fun signOut(erasingDevice: Boolean = false) = viewModelScope.launch {
polling?.cancel()
polling = null
val current = session
val myDevice = identity?.deviceId
session = null
withContext(Dispatchers.IO) { KeystoreVault.destroy(getApplication()) }
identity = null
vault = KeystoreVault.open(getApplication())
if (erasingDevice) {
withContext(Dispatchers.IO) {
// ⚠️ Tell the relay BEFORE destroying the keys that authorise
// saying it. Otherwise the account goes on publishing a device
// nobody will ever read again, and fan-out is per device — so
// contacts keep addressing copies to a dead mailbox while the
// relay reports every one of them delivered. Best effort: a bad
// network must not leave the phone half-erased.
if (myDevice != null) runCatching { current?.revokeDevice(myDevice) }
KeystoreVault.destroy(getApplication())
}
identity = null
vault = KeystoreVault.open(getApplication())
} else {
identity = null
}
_phase.value = Phase.SignedOut()
}