Android: member lookup, and the group gaps

**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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 15:02:00 -04:00
co-authored by Claude Opus 5
parent acee4597d5
commit c1284aab5f
9 changed files with 231 additions and 8 deletions
@@ -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()
@@ -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)
}
@@ -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()
@@ -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<io.flamenet.client.MessengerApi.Buddy> = 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<io.flamenet.client.MessengerApi.Buddy>,
) {
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") }
}
}
}
@@ -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)
@@ -47,6 +47,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val stories: List<Vault.Story> = 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 {
@@ -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.
*