From 41533baae6fdbaaf3a17a2a0c639f649c3fcd524 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 21:23:52 -0400 Subject: [PATCH] Group names survive a reinstall, and reach our own other devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/flamenet/client/Conversations.kt | 9 ++++- .../io/flamenet/client/GroupNameTest.kt | 39 +++++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 23 +++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index ac9b9e9..1705497 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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) } } } diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index ec85690..732971a 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -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 diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 7e79a3e..852f726 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -1144,6 +1144,13 @@ class Vault(private val storage: Storage, private val key: SecretKey) { .put("callLog", callLog()) .also { p -> readJson(STORIES)?.let { p.put("stories", it) } } .also { p -> readJson(ROSTER)?.let { p.put("roster", it) } } + // 🔴 **The relay does not hold group names any more**, so this + // device's copy is the only copy. Before that change a restored + // device got them back from the server for free; an export that + // carried the messages but not the names would restore a chat list + // of groups all called "Group" — the exact loss this file exists to + // prevent. + .also { p -> readJson(GROUP_NAMES)?.let { p.put("groupNames", it) } } } data class ImportCounts(val messages: Int, val stories: Int) @@ -1238,6 +1245,22 @@ class Vault(private val storage: Storage, private val key: SecretKey) { saveRoster(current, offServer || imported.optBoolean("offServer", false)) } + // 🔴 **Group names ride the backup, because nothing else can supply + // them now.** The relay stopped holding them, so a restored device that + // did not read them here would show every group as "Group" until some + // other member happened to send a message. + // + // ⚠️ Only for groups this device has no name for. A backup is older + // than the device's own state by definition, and a rename made since + // must not be undone by restoring. + payload.optJSONObject("groupNames")?.let { imported -> + for (gid in imported.keys()) { + if (groupName(gid) != null) continue + val name = imported.optString(gid) + if (name.isNotEmpty()) saveGroupName(gid, name) + } + } + return ImportCounts(newMessages, newStories) }