Group names survive a reinstall, and reach our own other devices

Two gaps the group-name change left behind.

The encrypted history export carried the messages but not the names. That
was harmless while the relay answered with them — a reinstalled device got
them back for free. It holds none now, so this device's copy is the only
copy, and restoring a backup would have produced a chat list of groups all
called "Group": exactly the loss the export exists to prevent. Names ride
the payload, and only fill gaps on import — a backup is older than the
device by definition, so a rename made since must not be undone.

And fan-out excludes our own account, so a second phone we own was told by
nobody: the relay has no name to serve it and the members only send to each
other. The announcement now addresses our own account too. The fan-out loop
already skipped our own device, so what goes out reaches our OTHER devices
and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 21:23:52 -04:00
co-authored by Claude Opus 4.8
parent d128e8d56e
commit 41533baae6
3 changed files with 69 additions and 2 deletions
@@ -1312,8 +1312,13 @@ class Conversations(
val key = vault.loadDelivery()?.key ?: return
val payload = SealedSender.deliveryKeyEnvelope(
key, groupId = groupId, groupName = name)
for (member in memberIds) {
if (member == myUserId) continue
// 🔴 **Our own account is included, and that is the point.** Fan-out
// excludes us everywhere else, so a second phone we own would be told
// by nobody: the relay has no name to serve it and the members only
// send to each other. The fan-out loop already skips our own device
// ("never talk to ourselves"), so what goes out is addressed to our
// OTHER devices and to nothing else.
for (member in memberIds + listOfNotNull(myUserId)) {
runCatching { sendPlaintext(member, payload) }
}
}
@@ -232,6 +232,45 @@ class GroupNameTest {
assertEquals(0, server.requestCount)
}
// -- Surviving a reinstall -------------------------------------------------
/**
* 🔴 **A restored backup must bring the group names with it.** The relay
* used to answer with the name, so a reinstalled device got it back for
* free; it holds none now, which makes the device's copy the only copy. An
* export carrying the messages but not the names restores a chat list of
* groups all called "Group" — the exact loss the export exists to stop.
*/
@Test
fun `an exported backup carries group names into a fresh device`() {
val old = vault()
old.saveGroupName("g1", "Book club")
val payload = old.exportPayload()
val fresh = vault()
assertNull(fresh.groupName("g1"))
fresh.mergeImportedPayload(payload, 0.0)
assertEquals("Book club", fresh.groupName("g1"))
}
/**
* ⚠️ A backup is older than the device by definition. A rename made since
* the export must not be undone by restoring it.
*/
@Test
fun `a restored backup does not overwrite a newer name`() {
val old = vault()
old.saveGroupName("g1", "Book club")
val payload = old.exportPayload()
val current = vault()
current.saveGroupName("g1", "Book club, Tuesdays")
current.mergeImportedPayload(payload, 0.0)
assertEquals("Book club, Tuesdays", current.groupName("g1"))
}
// -- Where it is kept ------------------------------------------------------
@Test