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 a7cd376..586ed93 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -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()) } + + 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( 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 63ada0b..de35337 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -304,6 +304,7 @@ private fun ColumnScope.SignedIn( PresencePicker(model, phase.presence) BuddyList(model, phase) + GroupsSection(model, phase) } @Composable 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 9ad7aa7..e6b649f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -39,13 +39,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val relay: String, val status: String, val buddies: List = emptyList(), + val groups: List = emptyList(), val blocked: Set = emptySet(), val notifications: List = 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 = emptySet(), + /** Threads with unread mail, so a list can say so without opening each. */ + val unread: Set = 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, + 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) = 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, remove: List) = + 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), ) } } 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 c5789e1..adff04d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -41,8 +41,8 @@ class Session( val attachment: Attachment.Ref? = null, ) - fun history(peerUserId: Long): List { - val stored = vault.history(peerUserId) + fun history(threadId: String): List { + 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 { + fun receive(): Set { 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() + 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 } } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 1ecb704..583c1b8 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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() + val failed = LinkedHashMap() + 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. * diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index b6b148e..d26f86d 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -195,17 +195,53 @@ class RelayClient( // -- Groups (§8.9) ------------------------------------------------------- - fun createGroup(name: String, memberIds: List): 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, + ) { + 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): 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 { + 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, remove: List): JSONObject = - postJson("/e2e/groups/$groupId/members", JSONObject() + fun updateGroupMembers(groupId: String, add: List, remove: List): 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()) diff --git a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt index 8e71d2b..f097b0b 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveConversationTest.kt @@ -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() private fun appPasswordOf(person: Person): String = diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 5c43fe2..13fb722 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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:` and `group:`; 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 = + /** Every thread this device holds, group or person. */ + fun threads(): List = 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.