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
@@ -117,7 +117,8 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
Text(
buddy.displayName.ifBlank { buddy.handle }.ifBlank { "user ${buddy.userId}" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (buddy.userId in phase.unread) FontWeight.Bold else null,
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
FontWeight.Bold else null,
)
// The handle beside the display name, because the handle is
// what you tell someone else to type.
@@ -129,7 +130,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
style = MaterialTheme.typography.bodySmall,
)
}
if (buddy.userId in phase.unread) {
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
Box(
modifier = Modifier
.size(10.dp)
@@ -157,7 +158,7 @@ fun ColumnScope.BuddyList(model: MessengerViewModel, phase: MessengerViewModel.P
modifier = Modifier.testTag("block-${buddy.userId}"),
) { Text(if (isBlocked) "Unblock" else "Block") }
TextButton(
onClick = { model.clearHistory(buddy.userId) },
onClick = { model.clearHistory(io.flamenet.e2e.Vault.threadForUser(buddy.userId)) },
modifier = Modifier.testTag("clear-${buddy.userId}"),
) { Text("Clear chat") }
TextButton(
@@ -219,6 +220,109 @@ private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerV
}
}
/**
* Groups: list them, make one, open one.
*
* ⚠️ Member names are resolved here, from the contact list. The relay stores
* ids and nothing else on purpose — it has no user table and should not grow
* one, because a relay that caches display names is a relay that leaks them.
* A member you have never added shows as an id, which is honest.
*/
@Composable
fun ColumnScope.GroupsSection(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
var creating by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") }
var chosen by remember { mutableStateOf(setOf<Long>()) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Groups", style = MaterialTheme.typography.headlineSmall)
TextButton(
onClick = { creating = !creating },
modifier = Modifier.testTag("new-group"),
) { Text(if (creating) "Cancel" else "New") }
}
if (creating) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Group name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("group-name"),
)
if (phase.buddies.isEmpty()) {
Text("Add a contact first — a group needs somebody in it.",
style = MaterialTheme.typography.bodySmall)
}
phase.buddies.forEach { buddy ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
chosen = if (buddy.userId in chosen) chosen - buddy.userId
else chosen + buddy.userId
}
.padding(vertical = 8.dp)
.testTag("group-member-${buddy.userId}"),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(if (buddy.userId in chosen) "☑" else "☐")
Text(buddy.displayName.ifBlank { buddy.handle })
}
}
Button(
onClick = {
model.createGroup(name.trim(), chosen.toList())
name = ""; chosen = emptySet(); creating = false
},
enabled = name.isNotBlank() && chosen.isNotEmpty() && !phase.busy,
modifier = Modifier.fillMaxWidth().testTag("create-group"),
) { Text("Create group") }
}
phase.groups.forEach { group ->
val thread = io.flamenet.e2e.Vault.threadForGroup(group.id)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openGroup(group) }
.padding(vertical = 12.dp)
.testTag("group-${group.id}"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
group.name.ifBlank { "Group" },
fontWeight = if (thread in phase.unread) FontWeight.Bold else null,
)
Text(
group.memberIds.joinToString(", ") { id ->
phase.buddies.firstOrNull { it.userId == id }
?.let { b -> b.displayName.ifBlank { b.handle } }
?: "user $id"
},
style = MaterialTheme.typography.bodySmall,
)
}
if (thread in phase.unread) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.testTag("unread-group-${group.id}"))
}
}
Divider()
}
}
/** Presence, which is a claim about you and therefore yours to set. */
@Composable
fun PresencePicker(model: MessengerViewModel, current: String) {
@@ -399,8 +503,20 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = model::closeChat, modifier = Modifier.testTag("back")) { Text("Back") }
Text(chat.peerName.ifBlank { "user ${chat.peerId}" },
style = MaterialTheme.typography.headlineSmall)
Column(modifier = Modifier.weight(1f)) {
Text(chat.title.ifBlank { "Conversation" },
style = MaterialTheme.typography.headlineSmall)
chat.group?.let {
Text("${it.memberIds.size} members",
style = MaterialTheme.typography.bodySmall)
}
}
chat.group?.let { group ->
TextButton(
onClick = { model.leaveGroup(group.id) },
modifier = Modifier.testTag("leave-group"),
) { Text("Leave", color = MaterialTheme.colorScheme.error) }
}
}
LazyColumn(
@@ -304,6 +304,7 @@ private fun ColumnScope.SignedIn(
PresencePicker(model, phase.presence)
BuddyList(model, phase)
GroupsSection(model, phase)
}
@Composable
@@ -39,13 +39,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val relay: String,
val status: String,
val buddies: List<MessengerApi.Buddy> = emptyList(),
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
val blocked: Set<Long> = emptySet(),
val notifications: List<MessengerApi.Notification> = emptyList(),
val presence: String = "online",
/** Which screen is showing. One field, so two cannot be open at once. */
val screen: Screen = Screen.CONTACTS,
/** People with unread mail, so the list can say so without opening each thread. */
val unread: Set<Long> = emptySet(),
/** Threads with unread mail, so a list can say so without opening each. */
val unread: Set<String> = emptySet(),
val openChat: Chat? = null,
val busy: Boolean = false,
val error: String? = null,
@@ -53,11 +54,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
/** An open conversation: who with, and everything said so far. */
/**
* An open conversation.
*
* One type for both kinds: a thread is a thread, and the only
* difference is whether it is addressed to a person or a group. Two
* types would mean two of everything downstream.
*/
data class Chat(
val peerId: Long,
val peerName: String,
val threadId: String,
val title: String,
val lines: List<Session.Line>,
val peerId: Long? = null,
val group: io.flamenet.client.RelayClient.Group? = null,
)
/**
@@ -123,6 +132,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay = SiteConfig(credential.relayBaseUrl).host,
status = "Signed in on this device.",
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
)
}
}
@@ -234,6 +244,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay = config.host,
status = "Registered with ${preKeys.size} one-time prekeys published.",
buddies = runCatching { session!!.api.buddies() }.getOrDefault(emptyList()),
groups = runCatching { session!!.relay.listGroups() }.getOrDefault(emptyList()),
)
}
@@ -267,18 +278,94 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun openChat(peerId: Long, peerName: String) {
val current = session ?: return
val thread = Vault.threadForUser(peerId)
update {
it.copy(
openChat = Phase.Chat(peerId, peerName, current.history(peerId)),
openChat = Phase.Chat(thread, peerName, current.history(thread), peerId = peerId),
// Opening the thread is what marks it read. Nothing is sent to
// the relay for this — read state is local, because a read
// receipt is a disclosure and this product does not make it by
// default.
unread = it.unread - peerId,
unread = it.unread - thread,
)
}
}
fun openGroup(group: io.flamenet.client.RelayClient.Group) {
val current = session ?: return
val thread = Vault.threadForGroup(group.id)
update {
it.copy(
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group),
unread = it.unread - thread,
)
}
}
// -- Groups --------------------------------------------------------------
fun refreshGroups() = viewModelScope.launch {
val current = session ?: return@launch
val fetched = withContext(Dispatchers.IO) { runCatching { current.relay.listGroups() } }
fetched.onSuccess { list -> update { it.copy(groups = list, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
fun createGroup(name: String, memberIds: List<Long>) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val made = withContext(Dispatchers.IO) {
runCatching {
current.relay.createGroup(name, memberIds)
current.relay.listGroups()
}
}
made.onSuccess { list -> update { it.copy(busy = false, groups = list) } }
.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
fun updateGroupMembers(groupId: String, add: List<Long>, remove: List<Long>) =
viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
current.relay.updateGroupMembers(groupId, add, remove)
current.relay.listGroups()
}
}
done.onSuccess { list ->
update { state ->
state.copy(
busy = false, groups = list,
openChat = state.openChat?.let { chat ->
chat.copy(group = list.firstOrNull { g -> g.id == groupId } ?: chat.group)
},
)
}
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
/**
* ⚠️ Leaving also clears the local thread. Staying would leave a
* conversation nobody can add to and no way to remove it — a dead thread
* the user cannot get rid of.
*/
fun leaveGroup(groupId: String) = viewModelScope.launch {
val current = session ?: return@launch
update { it.copy(busy = true, error = null) }
val done = withContext(Dispatchers.IO) {
runCatching {
current.relay.leaveGroup(groupId)
current.clearHistory(Vault.threadForGroup(groupId))
current.relay.listGroups()
}
}
done.onSuccess { list ->
update { it.copy(busy = false, groups = list, openChat = null) }
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
}
fun closeChat() = update { it.copy(openChat = null) }
fun showScreen(screen: Phase.Screen) = update { it.copy(screen = screen, error = null) }
@@ -309,11 +396,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* would make the next message from that person undecryptable — a "clear
* chat" that quietly breaks the chat.
*/
fun clearHistory(peerUserId: Long) = viewModelScope.launch {
fun clearHistory(threadId: String) = viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) { current.clearHistory(peerUserId) }
withContext(Dispatchers.IO) { current.clearHistory(threadId) }
update {
it.copy(openChat = it.openChat?.takeIf { chat -> chat.peerId == peerUserId }
it.copy(openChat = it.openChat?.takeIf { chat -> chat.threadId == threadId }
?.copy(lines = emptyList()) ?: it.openChat)
}
}
@@ -424,13 +511,13 @@ 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 { current.sendImage(chat.peerId!!, bytes, mime, width, height) }
}
done.onSuccess { sent ->
update {
it.copy(
busy = false,
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
error = if (sent.failed.isEmpty()) null
else "Delivered to ${sent.delivered.size} of " +
"${sent.delivered.size + sent.failed.size} devices.",
@@ -462,11 +549,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val chat = signedIn()?.openChat ?: return@launch
if (text.isBlank()) return@launch
val result = withContext(Dispatchers.IO) { runCatching { current.send(chat.peerId, text) } }
val result = withContext(Dispatchers.IO) {
runCatching {
val group = chat.group
if (group != null) current.sendGroup(group, text)
else current.send(chat.peerId!!, text)
}
}
result.onSuccess { sent ->
update {
it.copy(
openChat = it.openChat?.copy(lines = current.history(chat.peerId)),
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
// Partial delivery is stated, not hidden. "Sent" when one
// of someone's three devices got it is a lie the user acts
// on.
@@ -492,21 +585,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
while (true) {
val current = session
if (current != null) {
val senders = withContext(Dispatchers.IO) {
val threads = withContext(Dispatchers.IO) {
runCatching { current.receive() }.getOrDefault(emptySet())
}
if (senders.isNotEmpty()) {
if (threads.isNotEmpty()) {
update { state ->
val open = state.openChat
state.copy(
openChat = open?.let {
if (it.peerId in senders) {
it.copy(lines = current.history(it.peerId))
if (it.threadId in threads) {
it.copy(lines = current.history(it.threadId))
} else it
},
// A thread that is already open is being read,
// so it does not also become unread.
unread = state.unread + senders - setOfNotNull(open?.peerId),
unread = state.unread + threads - setOfNotNull(open?.threadId),
)
}
}
@@ -41,8 +41,8 @@ class Session(
val attachment: Attachment.Ref? = null,
)
fun history(peerUserId: Long): List<Line> {
val stored = vault.history(peerUserId)
fun history(threadId: String): List<Line> {
val stored = vault.history(threadId)
return (0 until stored.length()).map {
val m = stored.getJSONObject(it)
Line(
@@ -57,19 +57,21 @@ class Session(
}
fun record(
peerUserId: Long,
threadId: String,
text: String,
mine: Boolean,
attachment: Attachment.Ref? = null,
author: Long = 0,
at: Long = System.currentTimeMillis(),
) {
vault.append(peerUserId, JSONObject()
vault.append(threadId, JSONObject()
.put("author", author)
.put("text", text).put("mine", mine).put("at", at)
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
}
/** Clear a thread. Keeps the session, so the conversation still works. */
fun clearHistory(peerUserId: Long) = vault.forgetHistory(peerUserId)
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
/** Send an image. Returns what happened, same as a text send. */
fun sendImage(peerUserId: Long, bytes: ByteArray, mime: String, width: Int, height: Int):
@@ -79,7 +81,7 @@ class Session(
if (sent.reachedSomeone) {
// From the reference we just built, so the sender's own thread
// shows the image without fetching it back from the relay.
record(peerUserId, "", mine = true, attachment = sent.attachment)
record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
}
return sent
}
@@ -103,7 +105,21 @@ class Session(
*/
fun send(peerUserId: Long, text: String): Conversations.Sent {
val sent = conversations.send(peerUserId, text)
if (sent.reachedSomeone) record(peerUserId, text, mine = true)
if (sent.reachedSomeone) record(Vault.threadForUser(peerUserId), text, mine = true)
return sent
}
/**
* Send to a group, and file our own copy.
*
* ⚠️ Recorded even when nobody was reachable — a group of one, or a group
* whose other members have no devices yet, is a real situation and losing
* what you typed in it is not acceptable. The delivery counts still tell
* the caller what happened.
*/
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
val sent = conversations.sendToGroup(group, text, userId)
record(Vault.threadForGroup(group.id), text, mine = true, author = userId)
return sent
}
@@ -115,11 +131,20 @@ class Session(
* devices sent — travel down the same channel, and filing them would put
* raw JSON in the thread.
*/
fun receive(): Set<Long> {
fun receive(): Set<String> {
val arrived = conversations.poll().filter { it.content.isRenderable }
arrived.forEach {
record(it.fromUser, it.content.text, mine = false, attachment = it.content.attachment)
val touched = mutableSetOf<String>()
for (message in arrived) {
// ⚠️ A group message is filed under the GROUP, not the sender.
// Filing by sender scatters one conversation across as many threads
// as it has participants.
val thread = message.content.groupId
?.let(Vault::threadForGroup)
?: Vault.threadForUser(message.fromUser)
record(thread, message.content.text, mine = false,
attachment = message.content.attachment, author = message.fromUser)
touched += thread
}
return arrived.map { it.fromUser }.toSet()
return touched
}
}
@@ -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 =
+19 -14
View File
@@ -48,6 +48,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
private const val SESSION_PREFIX = "session/"
private const val HISTORY_PREFIX = "history/"
/** The two kinds of thread, named once so they cannot drift apart. */
fun threadForUser(userId: Long) = "user:$userId"
fun threadForGroup(groupId: String) = "group:$groupId"
private val random = SecureRandom()
}
@@ -168,31 +172,32 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
/**
* Messages, encrypted at rest, keyed by the person rather than the device.
*
* ⚠️ **Per person, not per device.** A conversation is with someone, and
* their phone and tablet are the same conversation — filing history by
* device would show the same person as several threads that each hold half
* the story.
* ⚠️ **Keyed by thread, not by device.** A conversation is with someone —
* or with a group — and their phone and tablet are the same conversation.
* Filing by device shows one person as several threads that each hold half
* the story. Thread ids are `user:<id>` and `group:<gid>`; see
* [threadForUser] and [threadForGroup].
*
* ⚠️ The whole thread is rewritten on every append. That is fine for a
* chat and would not be for a log; if a thread ever grows large enough for
* this to matter, the fix is an append-only record format, not a bigger
* buffer.
*/
fun history(peerUserId: Long): org.json.JSONArray =
readJson(historyName(peerUserId))?.optJSONArray("messages") ?: org.json.JSONArray()
fun history(threadId: String): org.json.JSONArray =
readJson(historyName(threadId))?.optJSONArray("messages") ?: org.json.JSONArray()
fun append(peerUserId: Long, message: JSONObject) {
val existing = history(peerUserId)
fun append(threadId: String, message: JSONObject) {
val existing = history(threadId)
existing.put(message)
writeJson(historyName(peerUserId), JSONObject().put("messages", existing))
writeJson(historyName(threadId), JSONObject().put("messages", existing))
}
/** Everyone this device has a thread with, whether or not a session survives. */
fun conversationPartners(): List<Long> =
/** Every thread this device holds, group or person. */
fun threads(): List<String> =
storage.names().filter { it.startsWith(HISTORY_PREFIX) }
.mapNotNull { it.removePrefix(HISTORY_PREFIX).toLongOrNull() }
.map { it.removePrefix(HISTORY_PREFIX) }
private fun historyName(peerUserId: Long) = HISTORY_PREFIX + peerUserId
private fun historyName(threadId: String) = HISTORY_PREFIX + threadId
// -- Forgetting ----------------------------------------------------------
@@ -207,7 +212,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
* make the next message from that person undecryptable — a "clear chat"
* that quietly breaks the chat.
*/
fun forgetHistory(peerUserId: Long) = storage.delete(historyName(peerUserId))
fun forgetHistory(threadId: String) = storage.delete(historyName(threadId))
/**
* Forget everything, identity included.