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
+3 -3
View File
@@ -97,8 +97,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| Photos | ✅ send via the system picker, render inline | | Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send | | Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted | | Stories, and seen-state | ✅ 24h, sent per-contact, expired bytes deleted |
| Groups: create, list, members, leave | ⚪ | | Groups: create, list, members, leave | ✅ members editable after creation |
| Group text and images | ⚪ | | Group text and images | ✅ one upload, one envelope per member |
| Delivery keys + rotation | ✅ published as a digest, rotatable from Account | | Delivery keys + rotation | ✅ published as a digest, rotatable from Account |
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread | | 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 | | 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 | | Presence (read and set) | ✅ shown from the relay, never inferred |
| Buddy list, add by username | ✅ | | Buddy list, add by username | ✅ |
| Remove, block, unblock | ✅ | | 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) | | Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ | | Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
| Notifications list, read state | ✅ | | Notifications list, read state | ✅ |
@@ -56,6 +56,10 @@ class ChatFlowTest {
// person actually has to type. // person actually has to type.
compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle) 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() compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000) awaitNode(hasText(peerHandle, substring = true), 60_000)
@@ -98,6 +102,10 @@ class ChatFlowTest {
signUp() signUp()
compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle) 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() compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000) awaitNode(hasText(peerHandle, substring = true), 60_000)
compose.onNode(hasText(peerHandle, substring = true)).performClick() compose.onNode(hasText(peerHandle, substring = true)).performClick()
@@ -94,6 +94,10 @@ class CrossPlatformTest {
if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) { if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) {
compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peer) 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() compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peer, substring = true), 90_000) awaitNode(hasText(peer, substring = true), 90_000)
} }
@@ -84,6 +84,10 @@ class CrudSurfaceTest {
compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle) 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() compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000) awaitNode(hasText(peerHandle, substring = true), 60_000)
@@ -134,6 +138,10 @@ class CrudSurfaceTest {
signUp() signUp()
compose.onNodeWithTag("add-contact").performClick() compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle) 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() compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle, substring = true), 60_000) awaitNode(hasText(peerHandle, substring = true), 60_000)
compose.onNode(hasText(peerHandle, substring = true)).performClick() compose.onNode(hasText(peerHandle, substring = true)).performClick()
@@ -83,18 +83,58 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
if (adding) { if (adding) {
OutlinedTextField( OutlinedTextField(
value = username, value = username,
onValueChange = { username = it }, onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text("Username") }, label = { Text("Username") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"), 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( Button(
onClick = { onClick = {
model.addBuddy(username.trim()) model.addBuddy(username.trim())
username = "" username = ""
adding = false adding = false
model.clearLookup()
}, },
enabled = username.isNotBlank() && !phase.busy, enabled = phase.lookedUp != null && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("confirm-contact"), modifier = Modifier.fillMaxWidth().testTag("confirm-contact"),
) { Text("Add contact") } ) { Text("Add contact") }
} }
@@ -563,7 +603,13 @@ private fun PresenceDot(status: String) {
/** One conversation. */ /** One conversation. */
@Composable @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("") } var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState() 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 // 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. // identities and no single answer, so this is for one-to-one threads only.
if (chat.peerId != null) IdentityPanel(model, chat) 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)) { Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
val chat = current.openChat val chat = current.openChat
if (chat != null) { if (chat != null) {
ChatScreen(model, chat, current.error) ChatScreen(model, chat, current.error, current.buddies)
} else { } else {
when (current.screen) { when (current.screen) {
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current) MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
@@ -47,6 +47,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val stories: List<Vault.Story> = emptyList(), val stories: List<Vault.Story> = emptyList(),
/** The story being viewed full-screen, if any. */ /** The story being viewed full-screen, if any. */
val openStory: Vault.Story? = null, 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", val presence: String = "online",
/** Which screen is showing. One field, so two cannot be open at once. */ /** Which screen is showing. One field, so two cannot be open at once. */
val screen: Screen = Screen.CONTACTS, val screen: Screen = Screen.CONTACTS,
@@ -666,7 +669,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val chat = signedIn()?.openChat ?: return@launch val chat = signedIn()?.openChat ?: return@launch
update { it.copy(busy = true, error = null) } update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) { 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 -> done.onSuccess { sent ->
update { update {
@@ -681,6 +691,39 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } } }.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) ------------------------------------------------------ // -- Stories (§6.6) ------------------------------------------------------
fun refreshStories() = viewModelScope.launch { fun refreshStories() = viewModelScope.launch {
@@ -140,6 +140,22 @@ class Session(
return sent 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. * Poll once, filing anything that arrives. Returns who now has new mail.
* *
@@ -158,6 +158,42 @@ class Conversations(
.copy(attachment = ref) .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<String>()
val failed = LinkedHashMap<String, String>()
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). * Post a story to several people at once (§6.6).
* *