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:
co-authored by
Claude Fable 5
parent
8ca63c4fa1
commit
7885f55849
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user