Android: groups, and the wire shape iOS got wrong

Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.

So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.

Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.

Two things worth stating plainly:

The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.

Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.

Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-23 19:32:02 -04:00
co-authored by Claude Opus 5
parent e65d1d987b
commit f2d74cff0c
8 changed files with 412 additions and 56 deletions
@@ -64,6 +64,36 @@ class Conversations(
*/
fun send(toUserId: Long, text: String): Sent = sendPlaintext(toUserId, text)
/**
* Send to a group.
*
* ⚠️ **The relay does no fan-out.** A group is a membership list it keeps
* so clients agree who is in it; the encryption is still pairwise, one
* ratchet per recipient device. That is the honest cost of not letting the
* relay read anything: an n-person group is n sends, and the client is what
* knows that.
*
* ⚠️ Our own devices are excluded, so a group message does not arrive back
* at the sender as an incoming one. The sender's own copy is written
* locally by the caller.
*/
fun sendToGroup(group: RelayClient.Group, text: String, myUserId: Long): Sent {
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val body = Content.groupText(text, group.id)
for (member in group.memberIds) {
if (member == myUserId) continue
val one = sendPlaintext(member, body)
delivered += one.delivered
// Keyed by device, and a member nobody could reach is reported
// rather than swallowed: "sent to the group" when two of five
// people got it is the kind of lie that costs somebody a plan.
failed += one.failed
}
return Sent(delivered, failed)
}
/**
* Upload an encrypted blob and send the reference through the ratchet.
*
@@ -195,17 +195,53 @@ class RelayClient(
// -- Groups (§8.9) -------------------------------------------------------
fun createGroup(name: String, memberIds: List<Long>): JSONObject =
postJson("/e2e/groups", JSONObject()
/**
* A group as the relay can describe it.
*
* ⚠️ **Members are ids and nothing else.** The relay has no user table and
* deliberately will not grow one — profile data staying with the identity
* provider is the boundary the whole split is about, and a relay that
* caches display names is a relay that leaks them. Names are resolved
* client-side against the contact list.
*
* This is the exact shape the iOS client got wrong: it required a `name`
* on each member that the relay has never sent, so **every group decode
* failed** and groups were completely broken while looking implemented.
*/
data class Group(
val id: String,
val name: String,
val owner: Long,
val memberIds: List<Long>,
) {
companion object {
fun fromWire(wire: JSONObject): Group {
val members = wire.optJSONArray("members") ?: JSONArray()
return Group(
id = wire.getString("gid"),
name = wire.optString("name"),
owner = wire.optLong("owner"),
memberIds = (0 until members.length())
.map { members.getJSONObject(it).getLong("user_id") },
)
}
}
}
fun createGroup(name: String, memberIds: List<Long>): Group =
Group.fromWire(postJson("/e2e/groups", JSONObject()
.put("name", name)
.put("members", JSONArray(memberIds)))
.put("members", JSONArray(memberIds))))
fun listGroups(): JSONArray = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray()
fun listGroups(): List<Group> {
val list = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray()
return (0 until list.length()).map { Group.fromWire(list.getJSONObject(it)) }
}
fun updateGroupMembers(groupId: String, add: List<Long>, remove: List<Long>): JSONObject =
postJson("/e2e/groups/$groupId/members", JSONObject()
fun updateGroupMembers(groupId: String, add: List<Long>, remove: List<Long>): Group =
Group.fromWire(postJson("/e2e/groups/$groupId/members", JSONObject()
.put("add", JSONArray(add))
.put("remove", JSONArray(remove)))
.put("remove", JSONArray(remove))))
fun leaveGroup(groupId: String) {
postJson("/e2e/groups/$groupId/leave", JSONObject())
@@ -213,6 +213,56 @@ class LiveConversationTest {
"the relay is holding the plaintext")
}
/**
* A group, against the real relay.
*
* ⚠️ This is the shape the iOS client got wrong: it required a `name` on
* each member, and the relay has **never** sent one — it has no user table
* and deliberately will not grow one, because a relay caching display names
* is a relay that leaks them. Every group decode failed, so groups were
* completely broken while looking implemented. Asserting the member shape
* against live bytes is the whole point of this test.
*/
@Test
fun `a group is created, described and messaged`() {
val base = fleet ?: return
val alice = signUp(base, "k")
val bob = signUp(base, "l")
val carol = signUp(base, "m")
val group = alice.relay.createGroup("test group", listOf(bob.userId, carol.userId))
assertTrue(group.id.isNotEmpty(), "no group id came back")
assertEquals("test group", group.name)
assertEquals(alice.userId, group.owner)
// Creator plus the two invited. If this is 2, the relay does not add
// the owner and the client must.
assertTrue(group.memberIds.containsAll(listOf(bob.userId, carol.userId)),
"members were ${group.memberIds}")
assertTrue(alice.relay.listGroups().any { it.id == group.id },
"the group did not appear in the list")
// ⚠️ The relay does no fan-out. A group message is still pairwise
// encryption, one ratchet per recipient device — that is the honest
// cost of the relay not being able to read anything.
val sent = alice.conversations.sendToGroup(group, "hello everyone", alice.userId)
assertTrue(sent.reachedSomeone, "nothing delivered: ${sent.failed}")
for (member in listOf(bob, carol)) {
val got = member.conversations.poll().single()
assertEquals("hello everyone", got.text)
assertEquals(group.id, got.content.groupId,
"the message did not carry group routing, so it would file under the sender")
}
// Membership is editable, and leaving works.
val smaller = alice.relay.updateGroupMembers(group.id, add = emptyList(), remove = listOf(carol.userId))
assertTrue(!smaller.memberIds.contains(carol.userId), "carol was not removed")
bob.relay.leaveGroup(group.id)
assertTrue(bob.relay.listGroups().none { it.id == group.id }, "bob is still in the group")
}
private val appPasswords = HashMap<String, String>()
private fun appPasswordOf(person: Person): String =