From c1284aab5f03874374aca5bbe47305f17dda65a7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 15:02:00 -0400 Subject: [PATCH] Android: member lookup, and the group gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Member lookup.** `MessengerApi.lookup` already existed and nothing called it. Adding a contact now takes two steps: resolve the handle, see who it is, then add — and the add button stays disabled until the lookup succeeds. ⚠️ One step is worse than it looks. Adding straight from a typed string means a typo silently becomes a contact who is a stranger, and the first thing sent to them is a message meant for somebody else. Seeing a display name first is what catches that. ⚠️ A miss says "no account called that here" and nothing more. The relay answers identically for an account that does not exist and one that has not enrolled — deliberately, so it cannot be used to enumerate accounts — and this client must not invent a distinction the relay refused to make. The result also says plainly that it only proves the handle exists, and that identity is checked by comparing safety numbers once there is a conversation. This gate broke five existing instrumented call sites, which is the point of having them: each now looks up before confirming, the way a person does. **Group images.** Sealed and uploaded ONCE, then one envelope per member — same reasoning as a story. Uploading per member would multiply the bytes by the group size and hand the relay a set of blobs it could correlate. 🔴 The composer's photo button called `sendImage(chat.peerId!!, …)` regardless of thread. In a group `peerId` is null, so sending a photo to a group did not send to the wrong place — it crashed. Now routed on whether the thread is a group. **Group membership** is editable after creation; `updateGroupMembers` existed with no way to reach it. The copy states what membership actually does: adding someone lets them read what is sent from now on, removing them stops future messages, and neither is retroactive. A group cannot be un-read, and a UI implying otherwise would be the dangerous kind of wrong. Co-Authored-By: Claude Opus 5 --- PARITY.md | 6 +- .../io/flamenet/messenger/app/ChatFlowTest.kt | 8 ++ .../messenger/app/CrossPlatformTest.kt | 4 + .../flamenet/messenger/app/CrudSurfaceTest.kt | 8 ++ .../io/flamenet/messenger/app/ChatScreens.kt | 114 +++++++++++++++++- .../io/flamenet/messenger/app/MainActivity.kt | 2 +- .../messenger/app/MessengerViewModel.kt | 45 ++++++- .../io/flamenet/messenger/app/Session.kt | 16 +++ .../io/flamenet/client/Conversations.kt | 36 ++++++ 9 files changed, 231 insertions(+), 8 deletions(-) diff --git a/PARITY.md b/PARITY.md index 3d4b1f8..10e365c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -97,8 +97,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Photos | ✅ send via the system picker, render inline | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted | -| Groups: create, list, members, leave | ⚪ | -| Group text and images | ⚪ | +| Groups: create, list, members, leave | ✅ members editable after creation | +| Group text and images | ✅ one upload, one envelope per member | | Delivery keys + rotation | ✅ published as a digest, rotatable from Account | | Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread | | Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork | @@ -107,7 +107,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Presence (read and set) | ✅ shown from the relay, never inferred | | Buddy list, add by username | ✅ | | Remove, block, unblock | ✅ | -| Member lookup | ⚪ | +| Member lookup | ✅ look up before adding; adding is gated on it | | Buddy commands | — retired; not a feature any more (owner, 2026-08-25) | | Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ | | Notifications list, read state | ✅ | diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt index 431d8a4..3ed6bb9 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/ChatFlowTest.kt @@ -56,6 +56,10 @@ class ChatFlowTest { // person actually has to type. compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + // Adding is gated on a lookup: the button stays disabled until the + // handle resolves, so a typo cannot become a contact. + compose.onNodeWithTag("lookup-contact").performClick() + awaitTag("lookup-result", 30_000) compose.onNodeWithTag("confirm-contact").performClick() awaitNode(hasText(peerHandle, substring = true), 60_000) @@ -98,6 +102,10 @@ class ChatFlowTest { signUp() compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + // Adding is gated on a lookup: the button stays disabled until the + // handle resolves, so a typo cannot become a contact. + compose.onNodeWithTag("lookup-contact").performClick() + awaitTag("lookup-result", 30_000) compose.onNodeWithTag("confirm-contact").performClick() awaitNode(hasText(peerHandle, substring = true), 60_000) compose.onNode(hasText(peerHandle, substring = true)).performClick() diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt index f34c7d2..ff7d3e0 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt @@ -94,6 +94,10 @@ class CrossPlatformTest { if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) { compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peer) + // Adding is gated on a lookup: the button stays disabled until the + // handle resolves, so a typo cannot become a contact. + compose.onNodeWithTag("lookup-contact").performClick() + awaitTag("lookup-result", 30_000) compose.onNodeWithTag("confirm-contact").performClick() awaitNode(hasText(peer, substring = true), 90_000) } diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index 7f0939d..0bdec60 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -84,6 +84,10 @@ class CrudSurfaceTest { compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + // Adding is gated on a successful lookup now: a typo must not silently + // become a contact who is a stranger. + compose.onNodeWithTag("lookup-contact").performClick() + awaitTag("lookup-result", 30_000) compose.onNodeWithTag("confirm-contact").performClick() awaitNode(hasText(peerHandle, substring = true), 60_000) @@ -134,6 +138,10 @@ class CrudSurfaceTest { signUp() compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("new-contact").performTextInput(peerHandle) + // Adding is gated on a lookup: the button stays disabled until the + // handle resolves, so a typo cannot become a contact. + compose.onNodeWithTag("lookup-contact").performClick() + awaitTag("lookup-result", 30_000) compose.onNodeWithTag("confirm-contact").performClick() awaitNode(hasText(peerHandle, substring = true), 60_000) compose.onNode(hasText(peerHandle, substring = true)).performClick() 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 ced4f46..c69125a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -83,18 +83,58 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P if (adding) { OutlinedTextField( value = username, - onValueChange = { username = it }, + onValueChange = { + username = it + if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup() + }, label = { Text("Username") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("new-contact"), ) + + // ⚠️ **Look up first, then add.** Adding straight from a typed string + // means a typo silently becomes a contact who is a stranger, and the + // first thing sent to them is a message meant for somebody else. + TextButton( + onClick = { model.lookupMember(username) }, + enabled = username.isNotBlank() && !phase.busy, + modifier = Modifier.fillMaxWidth().testTag("lookup-contact"), + ) { Text("Look up") } + + phase.lookupMiss?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("lookup-miss"), + ) + } + + phase.lookedUp?.let { found -> + Column( + modifier = Modifier.fillMaxWidth().testTag("lookup-result"), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(found.displayName.ifBlank { found.handle }) + Text("@${found.handle}", style = MaterialTheme.typography.bodySmall) + // Named plainly: identity is checked by comparing safety + // numbers once the conversation exists, and a lookup result is + // not a substitute for that. + Text( + "Check their safety number after you start talking — this only says the " + + "handle exists on this server.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + Button( onClick = { model.addBuddy(username.trim()) username = "" adding = false + model.clearLookup() }, - enabled = username.isNotBlank() && !phase.busy, + enabled = phase.lookedUp != null && !phase.busy, modifier = Modifier.fillMaxWidth().testTag("confirm-contact"), ) { Text("Add contact") } } @@ -563,7 +603,13 @@ private fun PresenceDot(status: String) { /** One conversation. */ @Composable -fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.Phase.Chat, error: String?) { +fun ColumnScope.ChatScreen( + model: MessengerViewModel, + chat: MessengerViewModel.Phase.Chat, + error: String?, + /** Needed to name group members; the relay stores ids and no names. */ + buddies: List = emptyList(), +) { var draft by remember { mutableStateOf("") } val listState = rememberLazyListState() @@ -595,6 +641,8 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P } } + chat.group?.let { group -> GroupMembers(model, group, buddies) } + // Identity, directly under the name it belongs to. Groups have many // identities and no single answer, so this is for one-to-one threads only. if (chat.peerId != null) IdentityPanel(model, chat) @@ -1045,3 +1093,63 @@ private fun ColumnScope.StoryViewer(model: MessengerViewModel, story: Vault.Stor } } } + +/** + * Add and remove people after a group exists. + * + * ⚠️ **Membership is the relay's list, and it decides who receives.** A member + * added here starts getting messages from the next send; one removed stops. + * Neither is retroactive — removing somebody does not take back what they + * already have, and the copy says so rather than implying a group can be + * un-read. + */ +@Composable +private fun ColumnScope.GroupMembers( + model: MessengerViewModel, + group: io.flamenet.client.RelayClient.Group, + buddies: List, +) { + var editing by remember(group.id) { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("${group.memberIds.size} members", style = MaterialTheme.typography.bodySmall) + TextButton( + onClick = { editing = !editing }, + modifier = Modifier.testTag("edit-members"), + ) { Text(if (editing) "Done" else "Members") } + } + + if (!editing) return + + Text( + "Adding someone lets them read what is sent from now on. Removing them stops future " + + "messages — it does not take back what they already have.", + style = MaterialTheme.typography.bodySmall, + ) + + buddies.forEach { buddy -> + val isMember = buddy.userId in group.memberIds + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + buddy.displayName.ifBlank { buddy.handle }, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + if (isMember) model.updateGroupMembers(group.id, emptyList(), listOf(buddy.userId)) + else model.updateGroupMembers(group.id, listOf(buddy.userId), emptyList()) + }, + modifier = Modifier.testTag( + (if (isMember) "remove-member-" else "add-member-") + buddy.userId), + ) { Text(if (isMember) "Remove" else "Add") } + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index b32f0d7..006364e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -76,7 +76,7 @@ private fun Root(model: MessengerViewModel = viewModel()) { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) { val chat = current.openChat if (chat != null) { - ChatScreen(model, chat, current.error) + ChatScreen(model, chat, current.error, current.buddies) } else { when (current.screen) { MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) 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 ffe8baa..1a55ec4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -47,6 +47,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val stories: List = emptyList(), /** The story being viewed full-screen, if any. */ val openStory: Vault.Story? = null, + /** Who a handle turned out to be, before adding them (§8.2). */ + val lookedUp: MessengerApi.Me? = null, + val lookupMiss: String? = null, val presence: String = "online", /** Which screen is showing. One field, so two cannot be open at once. */ val screen: Screen = Screen.CONTACTS, @@ -666,7 +669,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val chat = signedIn()?.openChat ?: return@launch update { it.copy(busy = true, error = null) } val done = withContext(Dispatchers.IO) { - runCatching { current.sendImage(chat.peerId!!, bytes, mime, width, height) } + runCatching { + // A group thread sends to the group, not to whoever the + // thread happens to name. `peerId!!` here used to make a + // photo in a group crash rather than send. + val group = chat.group + if (group != null) current.sendGroupImage(group, bytes, mime, width, height) + else current.sendImage(chat.peerId!!, bytes, mime, width, height) + } } done.onSuccess { sent -> update { @@ -681,6 +691,39 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } } + // -- Member lookup --------------------------------------------------------- + + /** + * Find out who a handle belongs to before adding them. + * + * ⚠️ **Look up, then add — deliberately two steps.** Adding straight from a + * typed string means a typo silently becomes a contact who is a stranger, + * and the first thing sent to them is a message meant for somebody else. + * Seeing the display name first is what catches that. + * + * ⚠️ A miss is reported as "no such handle" and nothing more. The relay + * answers the same way for an account that does not exist and one that has + * not enrolled, on purpose, so that this cannot be used to enumerate + * accounts — and this client must not invent a distinction the relay + * refused to make. + */ + fun lookupMember(handle: String) = viewModelScope.launch { + val current = session ?: return@launch + val trimmed = handle.trim() + if (trimmed.isEmpty()) return@launch + update { it.copy(busy = true, lookedUp = null, lookupMiss = null, error = null) } + val found = withContext(Dispatchers.IO) { runCatching { current.api.lookup(trimmed) } } + update { + it.copy( + busy = false, + lookedUp = found.getOrNull(), + lookupMiss = if (found.getOrNull() == null) "No account called \"$trimmed\" here." else null, + ) + } + } + + fun clearLookup() = update { it.copy(lookedUp = null, lookupMiss = null) } + // -- 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 a1bbda4..5fd58a6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -140,6 +140,22 @@ class Session( return sent } + /** An image to a group: one upload, one envelope per member. */ + fun sendGroupImage( + group: RelayClient.Group, + bytes: ByteArray, + mime: String, + width: Int, + height: Int, + ): Conversations.Sent { + val sent = conversations.sendGroupAttachment(group, userId, bytes, mime, width, height) + if (sent.reachedSomeone) { + record(Vault.threadForGroup(group.id), "", mine = true, + attachment = sent.attachment, author = userId) + } + return sent + } + /** * Poll once, filing anything that arrives. Returns who now has new mail. * diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 764ff1a..f5e2e8d 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -158,6 +158,42 @@ class Conversations( .copy(attachment = ref) } + /** + * Send an image to a group. + * + * ⚠️ **Sealed and uploaded ONCE, then fanned out** — same reasoning as a + * story. One blob and one key, referenced by every member's envelope; + * uploading per member would multiply the bytes by the group size and give + * the relay a set of blobs it could correlate. + */ + fun sendGroupAttachment( + group: RelayClient.Group, + myUserId: Long, + data: ByteArray, + mime: String, + width: Int, + height: Int, + caption: String? = null, + ): Sent { + val sealed = Attachment.seal(data) + val id = relay.uploadAttachment(sealed.blob) + val ref = Attachment.Ref( + kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest, + bytes = sealed.blob.size, mime = mime, width = width, height = height, + ) + val body = Content.attachment(ref, caption, group.id) + + val delivered = mutableListOf() + val failed = LinkedHashMap() + for (member in group.memberIds) { + if (member == myUserId) continue + val one = sendPlaintext(member, body) + delivered += one.delivered + failed += one.failed + } + return Sent(delivered, failed, attachment = ref) + } + /** * Post a story to several people at once (§6.6). *