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:
co-authored by
Claude Opus 4.8
parent
8061f54e96
commit
4a6be0ba6b
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user