Group names live on the devices, not the relay

The relay stopped storing them, so `createGroup` no longer sends one and
the `name` it returns is a placeholder. The real name is held in the
sealed vault and travels between members: on every group envelope, and as
an announcement riding the `delivery_key` control kind at creation and on
adding a member.

`delivery_key` rather than a new kind because a new one would arrive on
every phone running v5 as a bubble full of raw JSON.

An announced name is recorded as a CLAIM, not adopted. Conversations does
not know the rosters, and a name taken on the sender's word would let
anyone who can reach you write whatever they liked into your chat list
under a group heading you have no reason to doubt. Session checks it
against the relay's roster and only then stores it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 20:24:03 -04:00
co-authored by Claude Opus 4.8
parent 8061f54e96
commit 4a6be0ba6b
9 changed files with 525 additions and 22 deletions
@@ -362,7 +362,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
credential.relayBaseUrl, credential.username, credential.appPassword),
)
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
val fetchedGroups = runCatching { session!!.namedGroups() }
stored to Phase.SignedIn(
handle = credential.username,
userId = credential.userId,
@@ -556,7 +556,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fnid?.let { ContactCard.canonical("$it@${config.host}") }
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
val fetchedGroups = runCatching { session!!.namedGroups() }
// 🔴 Restore the strict key-log requirement the user chose. Without
// this it reverted to off on every launch: the switch was read from a
@@ -916,18 +916,30 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
fun refreshGroups() = viewModelScope.launch {
val current = session ?: return@launch
val fetched = withContext(Dispatchers.IO) { runCatching { current.relay.listGroups() } }
val fetched = withContext(Dispatchers.IO) {
runCatching { current.namedGroups() }
}
fetched.onSuccess { list -> update { it.copy(groups = list, error = null) } }
.onFailure { failure -> update { it.copy(error = describe(failure)) } }
}
/**
* Create a group and tell its members what it is called.
*
* ⚠️ The name goes to the members, never to the relay — `createGroup` takes
* only the roster. Announcing is best-effort: a member who is offline picks
* the name up from the first message instead, because every group envelope
* carries it.
*/
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()
val group = current.relay.createGroup(memberIds)
current.nameGroup(group.id, name)
current.announceGroupName(group.id, group.memberIds)
current.namedGroups()
}
}
made.onSuccess { list -> update { it.copy(busy = false, groups = list) } }
@@ -941,7 +953,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val done = withContext(Dispatchers.IO) {
runCatching {
current.relay.updateGroupMembers(groupId, add, remove)
current.relay.listGroups()
// Somebody just added has never been told the name, and
// would sit on the placeholder until the next message.
if (add.isNotEmpty()) current.announceGroupName(groupId, add)
current.namedGroups()
}
}
done.onSuccess { list ->
@@ -968,7 +983,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
runCatching {
current.relay.leaveGroup(groupId)
current.clearHistory(Vault.threadForGroup(groupId))
current.relay.listGroups()
// The name goes with it. It only ever lived here because the
// group did, and the server has no copy to fall back on.
current.vault.clearGroupName(groupId)
current.namedGroups()
}
}
done.onSuccess { list ->
@@ -2228,6 +2246,27 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// A picture arriving is a control message: it touches no
// thread, so it needs its own signal. Without this the
// Home circles showed initials until the next sign-in.
// A group name arriving is control traffic: it touches no
// thread, so it needs its own signal. Without this a group
// someone else named stayed on the relay's placeholder
// until the next sign-in — the server has no copy to fill
// it in from any more.
if (current.groupNamesUpdated) {
current.groupNamesUpdated = false
withContext(Dispatchers.IO) { runCatching { current.namedGroups() } }
.getOrNull()?.let { live ->
update { state ->
state.copy(
groups = live,
openChat = state.openChat?.let { chat ->
chat.copy(group = live.firstOrNull {
g -> g.id == chat.group?.id
} ?: chat.group)
},
)
}
}
}
if (current.conversations.avatarsUpdated) {
current.conversations.avatarsUpdated = false
val fresh = withContext(Dispatchers.IO) {
@@ -642,7 +642,8 @@ class Session(
// no-device guard; each gets an entry carrying the SAME group body and
// the group thread to restate. A member whose devices took it is not
// queued — resending to them would duplicate the message.
val body = io.flamenet.e2e.Content.groupText(text, group.id)
val body = io.flamenet.e2e.Content.groupText(
text, group.id, groupName = vault.groupName(group.id))
sent.failed.keys.filter { it.startsWith("user:") }
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
.forEach { member ->
@@ -689,6 +690,9 @@ class Session(
fun receive(): Set<String> {
val everything = conversations.poll()
// Control traffic consumed during the poll may have carried group-name
// claims. Checked against the roster here, where the roster is known.
drainGroupNames()
everything.forEach { message ->
// ⚠️ Local calls only. Signalling is addressed by user id the
// whole way down — the answer, the ICE candidates, the hang-up —
@@ -745,6 +749,14 @@ class Session(
if (known == null || message.fromUser !in known) {
continue
}
// ⚠️ Every group envelope carries the group's name, and this is
// the repair path for it. The announcement sent at creation
// reaches only who was online and only the devices that existed
// then; a phone set up later would otherwise sit on the relay's
// generic placeholder for ever. The membership check above has
// already run, so this is not a way in for a stranger.
message.content.raw?.optString("gname")?.takeIf { it.isNotEmpty() }
?.let { adoptGroupName(gid, it) }
}
// 🔴 **A federated message files under its ADDRESS.** Their relay
// told ours nothing about who sent it, so `fromUser` is 0 — filing
@@ -894,6 +906,76 @@ class Session(
*/
private var groupRosterCache: Pair<Long, Map<String, Set<Long>>>? = null
/**
* The groups this account is in, each carrying the name we hold locally.
*
* 🔴 **The relay's `name` is a placeholder for every group.** It stopped
* storing names — it never read them, and a column holding "Chemotherapy
* support" is a column that can be dumped or subpoenaed — and answers with
* one generic word so builds that predate the change do not draw a blank
* row. Everything above this reads `Group.name`, so the substitution
* happens once, here, rather than at every screen that shows a group.
*/
fun namedGroups(): List<RelayClient.Group> = relay.listGroups().map { group ->
vault.groupName(group.id)?.let { group.copy(name = it) } ?: group
}
/** Name a group on this device. The relay is not told. */
fun nameGroup(gid: String, name: String) {
val trimmed = name.trim()
if (trimmed.isNotEmpty()) vault.saveGroupName(gid, trimmed)
}
/**
* Push a group's name to its members over their pairwise ratchets.
*
* Rides the `delivery_key` control kind rather than a new one — a new kind
* would arrive on every phone running v5 as a bubble full of raw JSON. See
* `SealedSender.deliveryKeyEnvelope`.
*
* ⚠️ Best-effort per member: one unreachable device must not stop the rest
* of the group learning what the group is called.
*/
fun announceGroupName(gid: String, memberIds: List<Long>) =
conversations.announceGroupName(gid, memberIds)
/**
* Record a name a member gave a group, if it is news.
*
* ⚠️ Callers check membership first. There is no path here that takes a
* name on the sender's word.
*/
private fun adoptGroupName(gid: String, name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty() || vault.groupName(gid) == trimmed) return
vault.saveGroupName(gid, trimmed)
groupNamesUpdated = true
}
/**
* Adopt the group-name claims that arrived as control traffic, checking
* each against the relay's roster.
*
* 🔴 **The check is the whole point.** A claim naming a group we are not in
* — or one from somebody who is not in it — is dropped. Without that,
* anyone able to reach us could write whatever they liked into our chat
* list under a group heading the reader has no reason to doubt.
*/
private fun drainGroupNames() {
for ((fromUser, gid, name) in conversations.takeClaimedGroupNames()) {
val known = groupRoster(gid) ?: continue
if (fromUser !in known) continue
adoptGroupName(gid, name)
}
}
/**
* Set when a group name changed, so the UI knows to re-read the list.
* Control traffic touches no thread, so without a flag a name that arrived
* mid-session stayed invisible until the next sign-in.
*/
@Volatile var groupNamesUpdated: Boolean = false
private fun groupRoster(gid: String): Set<Long>? {
val now = System.currentTimeMillis()
val cached = groupRosterCache