The story audience keeps its meaning when it crosses to an iPhone

This app stores the audience mode as its enum name, EXCEPT, and the iPhone
writes and reads it lower case. The export started carrying the field one
commit ago, so a backup moving between the two phones would have had the
mode dropped in silence.

Dropped is not neutral here. An audience that fails to restore is an
account back on everyone, which shows stories to the people somebody had
deliberately left out, and nothing on screen would have said so.

Converted in both directions where the ok/connected rename already lives,
and asserted in both directions, because a one-way test would pass on two
Android phones and still lose this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 14:12:40 -04:00
co-authored by Claude Opus 5
parent f9463d8ba6
commit 5fda2954c1
2 changed files with 25 additions and 4 deletions
@@ -141,7 +141,18 @@ object BackupPayload {
}
// §16.3. State somebody set on purpose, under the iPhone's names.
internal.optJSONObject("storyAudience")?.let { root.put("storyAudience", it) }
internal.optJSONObject("storyAudience")?.let { a ->
// This app stores the enum's NAME, which is upper case; the iPhone
// writes lower case and reads nothing else, so an audience crossing
// between the two would have been dropped in silence and the person
// would have restored a phone that shows their stories to everyone.
root.put(
"storyAudience",
JSONObject()
.put("mode", a.optString("mode").lowercase())
.put("ids", a.optJSONArray("ids") ?: JSONArray()),
)
}
internal.optJSONObject("storyMutes")?.optJSONArray("ids")?.takeIf { it.length() > 0 }?.let {
root.put("mutedStoryAuthors", it)
}
@@ -331,7 +342,14 @@ object BackupPayload {
out.put("storyReactions", it)
}
shared.optJSONObject("storyAudience")?.let { out.put("storyAudience", it) }
shared.optJSONObject("storyAudience")?.let { a ->
out.put(
"storyAudience",
JSONObject()
.put("mode", a.optString("mode").uppercase())
.put("ids", a.optJSONArray("ids") ?: JSONArray()),
)
}
shared.optJSONArray("mutedStoryAuthors")?.takeIf { it.length() > 0 }?.let {
out.put("storyMutes", JSONObject().put("ids", it))
}
@@ -104,7 +104,7 @@ 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("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?")))
@@ -221,6 +221,9 @@ class BackupPayloadTest {
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.
// The iPhone writes this lower case and reads nothing else, so an
// audience that travelled as this app's enum name would be dropped in
// silence and restore a phone showing stories to everyone.
assertEquals("except", shared.getJSONObject("storyAudience").getString("mode"))
assertEquals(9L, shared.getJSONArray("mutedStoryAuthors").getLong(0))
assertEquals("half a thought", shared.getJSONObject("drafts").getString("u:7"))
@@ -229,7 +232,7 @@ class BackupPayloadTest {
assertEquals("aGVsZA==", shared.getJSONObject("presenceTokensHeld").getString("9"))
val back = roundTrip()
assertEquals("except", back.getJSONObject("storyAudience").getString("mode"))
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"))