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:
co-authored by
Claude Opus 5
parent
fd3a52b35c
commit
f9463d8ba6
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1360,6 +1360,37 @@ class Vault(
|
||||
// Who reacted to which story. An export that leaves these behind
|
||||
// restores an author whose stories have quietly lost their tallies.
|
||||
.also { p -> readJson(STORY_REACTIONS)?.let { p.put("storyReactions", it) } }
|
||||
// Everything below is state somebody set on purpose (§16.3). An
|
||||
// export that leaves it behind restores an account that has
|
||||
// forgotten the person's choices without ever saying so.
|
||||
.also { p -> readJson(STORY_AUDIENCE)?.let { p.put("storyAudience", it) } }
|
||||
.also { p -> readJson(STORY_MUTES)?.let { p.put("storyMutes", it) } }
|
||||
.also { p -> readJson(STORY_ARCHIVE)?.let { p.put("storyArchive", it) } }
|
||||
.also { p -> readJson(DRAFTS)?.let { p.put("drafts", it) } }
|
||||
// A poll waits for every member, so pieces already gathered would
|
||||
// be lost the first time the app was closed on a new phone.
|
||||
.also { p -> readJson(POLLS)?.let { p.put("polls", it) } }
|
||||
// §8.9c, per-contact and carried: a new phone without them loses
|
||||
// every contact's presence and issues fresh tokens nobody holds.
|
||||
// Only these two maps, never the delivery record around them, which
|
||||
// holds this account's OWN key and §16.1 excludes.
|
||||
.also { p ->
|
||||
loadDelivery()?.let { d ->
|
||||
if (d.presenceIssued.isNotEmpty()) {
|
||||
p.put("presenceTokensIssued", longKeyed(d.presenceIssued))
|
||||
}
|
||||
if (d.presenceHeld.isNotEmpty()) {
|
||||
p.put("presenceTokensHeld", longKeyed(d.presenceHeld))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON object keys cannot be numbers, so a contact id travels as text. */
|
||||
private fun longKeyed(map: Map<Long, String>): JSONObject {
|
||||
val out = JSONObject()
|
||||
map.forEach { (id, value) -> out.put(id.toString(), value) }
|
||||
return out
|
||||
}
|
||||
|
||||
data class ImportCounts(
|
||||
@@ -1498,9 +1529,78 @@ class Vault(
|
||||
if (changed) writeJson(STORY_REACTIONS, existing)
|
||||
}
|
||||
|
||||
// Gaps only, like the group names: a choice this device has already
|
||||
// made outranks the one in a file that may be months old.
|
||||
payload.optJSONObject("storyAudience")?.let {
|
||||
if (readJson(STORY_AUDIENCE) == null) writeJson(STORY_AUDIENCE, it)
|
||||
}
|
||||
payload.optJSONObject("storyMutes")?.optJSONArray("ids")?.let { incoming ->
|
||||
val merged = storyMutes().toMutableSet()
|
||||
for (i in 0 until incoming.length()) merged += incoming.optLong(i)
|
||||
if (merged.isNotEmpty()) saveStoryMutes(merged)
|
||||
}
|
||||
payload.optJSONObject("storyArchive")?.let {
|
||||
if (readJson(STORY_ARCHIVE) == null) writeJson(STORY_ARCHIVE, it)
|
||||
}
|
||||
payload.optJSONObject("drafts")?.let { imported ->
|
||||
val existing = readJson(DRAFTS) ?: JSONObject()
|
||||
var changed = false
|
||||
for (thread in imported.keys()) {
|
||||
// A draft typed on THIS phone is the newer thought, and losing
|
||||
// it to a restore is losing something never sent anywhere.
|
||||
if (existing.optString(thread).isNotEmpty()) continue
|
||||
val text = imported.optString(thread)
|
||||
if (text.isEmpty()) continue
|
||||
existing.put(thread, text)
|
||||
changed = true
|
||||
}
|
||||
if (changed) writeJson(DRAFTS, existing)
|
||||
}
|
||||
payload.optJSONObject("polls")?.let {
|
||||
if (readJson(POLLS) == null) writeJson(POLLS, it)
|
||||
}
|
||||
mergePresenceTokens(payload)
|
||||
|
||||
return ImportCounts(newMessages, newStories)
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.9c tokens from a backup, gaps only.
|
||||
*
|
||||
* A token this device already holds is the live one, the relay knows its
|
||||
* digest, and replacing it with an older copy would make this account
|
||||
* invisible to that contact until they issued another.
|
||||
*/
|
||||
private fun mergePresenceTokens(payload: JSONObject) {
|
||||
val current = loadDelivery() ?: return
|
||||
val issued = current.presenceIssued.toMutableMap()
|
||||
val held = current.presenceHeld.toMutableMap()
|
||||
var changed = false
|
||||
payload.optJSONObject("presenceTokensIssued")?.let { imported ->
|
||||
for (who in imported.keys()) {
|
||||
val id = who.toLongOrNull() ?: continue
|
||||
if (issued.containsKey(id)) continue
|
||||
imported.optString(who).takeIf { it.isNotEmpty() }?.let {
|
||||
issued[id] = it
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
payload.optJSONObject("presenceTokensHeld")?.let { imported ->
|
||||
for (who in imported.keys()) {
|
||||
val id = who.toLongOrNull() ?: continue
|
||||
if (held.containsKey(id)) continue
|
||||
imported.optString(who).takeIf { it.isNotEmpty() }?.let {
|
||||
held[id] = it
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
saveDelivery(current.copy(presenceIssued = issued, presenceHeld = held))
|
||||
}
|
||||
}
|
||||
|
||||
/** What makes a line *the same message* across two devices. */
|
||||
private fun lineKey(m: JSONObject): String =
|
||||
"${m.optBoolean("mine")}|${m.optLong("at")}|${m.optString("text")}|" +
|
||||
|
||||
@@ -104,7 +104,12 @@ class BackupPayloadTest {
|
||||
).put(
|
||||
"storyReactions",
|
||||
JSONObject().put("story-1", JSONObject().put("9", "\u2764\ufe0f")),
|
||||
)
|
||||
).put("storyAudience", JSONObject().put("mode", "except").put("ids", JSONArray().put(9L)))
|
||||
.put("storyMutes", JSONObject().put("ids", JSONArray().put(9L)))
|
||||
.put("drafts", JSONObject().put("u:7", "half a thought"))
|
||||
.put("polls", JSONObject().put("poll-1", JSONObject().put("q", "saturday?")))
|
||||
.put("presenceTokensIssued", JSONObject().put("9", "aXNzdWVk"))
|
||||
.put("presenceTokensHeld", JSONObject().put("9", "aGVsZA=="))
|
||||
|
||||
private fun roundTrip(): JSONObject = BackupPayload.fromShared(BackupPayload.toShared(internalPayload()))
|
||||
|
||||
@@ -208,6 +213,30 @@ class BackupPayloadTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 16.3: an export that leaves these behind restores an account that has
|
||||
* quietly forgotten the person's choices. Both engines had shipped that.
|
||||
*/
|
||||
@Test
|
||||
fun `the choices somebody made survive the round trip`() {
|
||||
val shared = BackupPayload.toShared(internalPayload())
|
||||
// Under the iPhone's names, or it writes a file this app cannot read.
|
||||
assertEquals("except", shared.getJSONObject("storyAudience").getString("mode"))
|
||||
assertEquals(9L, shared.getJSONArray("mutedStoryAuthors").getLong(0))
|
||||
assertEquals("half a thought", shared.getJSONObject("drafts").getString("u:7"))
|
||||
assertEquals("saturday?", shared.getJSONObject("polls").getJSONObject("poll-1").getString("q"))
|
||||
assertEquals("aXNzdWVk", shared.getJSONObject("presenceTokensIssued").getString("9"))
|
||||
assertEquals("aGVsZA==", shared.getJSONObject("presenceTokensHeld").getString("9"))
|
||||
|
||||
val back = roundTrip()
|
||||
assertEquals("except", back.getJSONObject("storyAudience").getString("mode"))
|
||||
assertEquals(9L, back.getJSONObject("storyMutes").getJSONArray("ids").getLong(0))
|
||||
assertEquals("half a thought", back.getJSONObject("drafts").getString("u:7"))
|
||||
assertEquals("saturday?", back.getJSONObject("polls").getJSONObject("poll-1").getString("q"))
|
||||
assertEquals("aXNzdWVk", back.getJSONObject("presenceTokensIssued").getString("9"))
|
||||
assertEquals("aGVsZA==", back.getJSONObject("presenceTokensHeld").getString("9"))
|
||||
}
|
||||
|
||||
// -- And back again ------------------------------------------------------.
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user