From 7885f55849ff6f07a371da280a72d68be91dcb75 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 17:05:12 -0400 Subject: [PATCH] =?UTF-8?q?Registered=20devices=20card=20on=20Account=20?= =?UTF-8?q?=E2=80=94=20list,=20date,=20and=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/messenger/app/ChatScreens.kt | 107 ++++++++++++++++++ .../messenger/app/MessengerViewModel.kt | 25 ++++ .../io/flamenet/messenger/app/Session.kt | 20 ++++ .../kotlin/io/flamenet/client/RelayClient.kt | 8 ++ 4 files changed, 160 insertions(+) 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 a5ae665..f6109e1 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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(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") } + } + } + } + } +} 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 52e9fdb..c29d822 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 = 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 { 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 2c27a59..6b3070b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { + 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, diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 5851a0d..d4f2dab 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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. */