The export carries the choices somebody made, not only their messages

Following the SPEC 16.3 rule the same sweep just wrote down. The export
held threads, groups, calls, stories, the roster and the group keys, and
left behind the story audience, muted authors, the story archive, drafts,
polls in flight and the 8.9c presence tokens. Restoring on a new phone
lost every one of them silently, which looks exactly like a person who
never set them.

They travel under the names the iPhone already writes, so a backup moves
between the two phones rather than only between two Android ones. The
presence tokens travel as the two per-contact maps alone, never the
delivery record around them, which holds this account's own key and 16.1
excludes.

Import merges into gaps rather than overwriting: a draft typed on THIS
phone is the newer thought, and a presence token this device already
holds is the live one the relay knows the digest of, so an older copy
from a file would make this account invisible to that contact until they
issued another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 14:10:47 -04:00
co-authored by Claude Opus 5
parent fd3a52b35c
commit f9463d8ba6
3 changed files with 172 additions and 1 deletions
@@ -140,6 +140,27 @@ object BackupPayload {
root.put("storyReactions", it)
}
// §16.3. State somebody set on purpose, under the iPhone's names.
internal.optJSONObject("storyAudience")?.let { root.put("storyAudience", it) }
internal.optJSONObject("storyMutes")?.optJSONArray("ids")?.takeIf { it.length() > 0 }?.let {
root.put("mutedStoryAuthors", it)
}
internal.optJSONObject("storyArchive")?.optJSONArray("items")?.let { items ->
val out = JSONArray()
for (i in 0 until items.length()) {
items.optJSONObject(i)?.let { out.put(storyToShared(it)) }
}
if (out.length() > 0) root.put("storyArchive", out)
}
internal.optJSONObject("drafts")?.takeIf { it.length() > 0 }?.let { root.put("drafts", it) }
internal.optJSONObject("polls")?.takeIf { it.length() > 0 }?.let { root.put("polls", it) }
internal.optJSONObject("presenceTokensIssued")?.takeIf { it.length() > 0 }?.let {
root.put("presenceTokensIssued", it)
}
internal.optJSONObject("presenceTokensHeld")?.takeIf { it.length() > 0 }?.let {
root.put("presenceTokensHeld", it)
}
return root
}
@@ -310,6 +331,27 @@ object BackupPayload {
out.put("storyReactions", it)
}
shared.optJSONObject("storyAudience")?.let { out.put("storyAudience", it) }
shared.optJSONArray("mutedStoryAuthors")?.takeIf { it.length() > 0 }?.let {
out.put("storyMutes", JSONObject().put("ids", it))
}
shared.optJSONArray("storyArchive")?.takeIf { it.length() > 0 }?.let { items ->
val restored = JSONArray()
for (i in 0 until items.length()) {
val s = items.optJSONObject(i) ?: continue
restored.put(storyFromShared(s, s.optLong("author", 0L)))
}
if (restored.length() > 0) out.put("storyArchive", JSONObject().put("items", restored))
}
shared.optJSONObject("drafts")?.takeIf { it.length() > 0 }?.let { out.put("drafts", it) }
shared.optJSONObject("polls")?.takeIf { it.length() > 0 }?.let { out.put("polls", it) }
shared.optJSONObject("presenceTokensIssued")?.takeIf { it.length() > 0 }?.let {
out.put("presenceTokensIssued", it)
}
shared.optJSONObject("presenceTokensHeld")?.takeIf { it.length() > 0 }?.let {
out.put("presenceTokensHeld", it)
}
return out
}