Registered devices card on Account — list, date, and remove

Same contract as iOS: the relay's roster for this account, oldest
first, the current install marked and not removable from itself (that
is Sign out), removal behind an in-place "Really remove" confirmation
per this screen's house style. RelayClient grows revokeDevice
(DELETE /e2e/devices/{id}); Session surfaces ownDevices()/revokeDevice
and parses the directory's new created_at.

The relay caps accounts at three devices, so this card is the only way
off the cap when registration refuses a fourth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 17:05:12 -04:00
co-authored by Claude Fable 5
parent 8ca63c4fa1
commit 7885f55849
4 changed files with 160 additions and 0 deletions
@@ -657,6 +657,9 @@ fun ColumnScope.AccountScreen(
SectionBand()
SettingsSection { AppLockSection() }
SectionBand()
SettingsSection { RegisteredDevicesSection(model, phase) }
SectionBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
@@ -2376,3 +2379,107 @@ fun ColumnScope.AddContactForm(
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") }
}
/**
* The account's registered devices — every one is a full mailbox and a prekey
* pool on the relay, which caps them at three.
*
* 🔴 **This card is the ONLY way off the cap.** Registration refuses a fourth
* device, and every reinstall that didn't sign out leaves a corpse behind —
* sends still fan out to it, and it counts against the limit. The remedy has
* to be user-visible, or "the limit is 3" is a support ticket.
*/
@Composable
fun RegisteredDevicesSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
LaunchedEffect(Unit) { model.refreshDevices() }
var confirming by remember { mutableStateOf<String?>(null) }
Text("Registered devices", style = MaterialTheme.typography.titleMedium)
Text(
"${phase.registeredDevices.size} of 3 registered. Each device holds its "
+ "own keys and its own mailbox; removing one deletes its undelivered "
+ "messages and signs it out for good.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
if (phase.registeredDevices.isEmpty()) {
Text(
"Couldn't load the device list. Check your connection.",
fontSize = 14.sp,
color = Palette.SecondaryText,
)
return
}
phase.registeredDevices
.sortedBy { it.createdAt ?: "" }
.forEach { device ->
val isThis = device.deviceId == phase.deviceId
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Space.xs)
.testTag("device-${device.deviceId}"),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
device.deviceId.take(8),
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
if (isThis) {
Spacer(Modifier.width(Space.sm))
Text(
"This device",
fontSize = 11.sp,
color = Palette.Accent,
fontWeight = FontWeight.SemiBold,
)
}
}
Text(
device.createdAt?.let { raw ->
runCatching {
val instant = java.time.Instant.parse(raw)
val fmt = java.time.format.DateTimeFormatter
.ofPattern("MMM d, yyyy HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"Registered ${'$'}{fmt.format(instant)}"
}.getOrDefault("Registered $raw")
} ?: "Registered date unknown",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
// ⚠️ The device you are holding is not removable from itself —
// that is Sign out, which also wipes the local keys. Removing
// it here would leave this app confidently talking to a
// mailbox that no longer exists.
if (!isThis) {
if (confirming == device.deviceId) {
androidx.compose.material3.TextButton(
onClick = {
confirming = null
model.removeDevice(device.deviceId)
},
modifier = Modifier.testTag("confirm-remove-${device.deviceId}"),
) {
Text("Really remove", color = MaterialTheme.colorScheme.error)
}
} else {
androidx.compose.material3.TextButton(
onClick = { confirming = device.deviceId },
modifier = Modifier.testTag("remove-${device.deviceId}"),
) { Text("Remove") }
}
}
}
}
}
@@ -125,6 +125,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* shell level, so whichever screen picked it, the same sheet shows.
*/
val storyDraft: StoryDraft? = null,
/** The relay's device roster for THIS account — the Devices card. */
val registeredDevices: List<Session.RegisteredDevice> = emptyList(),
val presence: String = "online",
/** Which screen is showing. One field, so two cannot be open at once. */
val screen: Screen = Screen.CONTACTS,
@@ -1226,6 +1228,29 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun clearLookup() = update { it.copy(lookedUp = null, lookupMiss = null) }
// -- Registered devices (§8.3a) ------------------------------------------
fun refreshDevices() = viewModelScope.launch {
val current = session ?: return@launch
val fetched = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } }
fetched.onSuccess { list -> update { it.copy(registeredDevices = list) } }
}
fun removeDevice(deviceId: String) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) { runCatching { current.revokeDevice(deviceId) } }
done.onSuccess {
val fresh = withContext(Dispatchers.IO) { runCatching { current.ownDevices() } }
update {
it.copy(busy = false,
registeredDevices = fresh.getOrDefault(it.registeredDevices))
}
}.onFailure { failure ->
update { it.copy(busy = false, error = describe(failure)) }
}
}
// -- Stories (§6.6) ------------------------------------------------------
fun refreshStories() = viewModelScope.launch {
@@ -663,6 +663,26 @@ class Session(
fun shareAvatarIfNeeded(toUserId: Long) = conversations.shareAvatarIfNeeded(toUserId)
/** One row on the Devices screen. */
data class RegisteredDevice(val deviceId: String, val createdAt: String?)
/** This account's devices, from the relay directory (§8.3). */
fun ownDevices(): List<RegisteredDevice> {
val arr = relay.listDevices(userId)
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RegisteredDevice(
deviceId = o.optString("device_id"),
createdAt = o.optString("created_at").takeIf { it.isNotEmpty() },
)
}
}
/** Remove one of this account's devices — prekeys and queued mail go too. */
fun revokeDevice(deviceId: String) {
relay.revokeDevice(deviceId)
}
fun postStory(
bytes: ByteArray,
mime: String,
@@ -108,6 +108,14 @@ class RelayClient(
fun listDevices(userId: Long): JSONArray =
getJson("/e2e/devices/$userId").optJSONArray("devices") ?: JSONArray()
/**
* `DELETE /e2e/devices/{id}` — remove one of the ACCOUNT'S OWN devices.
* The relay refuses anyone else's, and drops the device's prekeys and
* queued mail with it (§8.3a).
*/
fun revokeDevice(deviceId: String): JSONObject =
requestJson("DELETE", url("/e2e/devices/$deviceId"), "/e2e/devices/$deviceId", null)
// -- Messages ------------------------------------------------------------
/** One envelope for one of the recipient's devices. */