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:
co-authored by
Claude Opus 5
parent
e65d1d987b
commit
f2d74cff0c
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user