Pins survive a backup, and a pin keeps what this app cannot show

The vector's canonical set just widened to every field both engines carry,
and pins was the one this app failed: it never read or wrote them, so a
pinned message was lost crossing in either direction and nothing said so.

A pin is kept whole now, at and by included, though this app shows
neither. Dropping a field an engine cannot render turns a backup into a
lossy hop: restore an iPhone through an Android phone and the pins come
back stripped of who pinned them and when. The vault keeps the record
rather than just the mid, so setting a pin here no longer discards what
travelled with it.

Import merges into gaps, like the drafts beside it: a pin set on THIS
phone is the current one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 14:40:15 -04:00
co-authored by Claude Opus 5
parent 5fda2954c1
commit 1049c1d118
2 changed files with 51 additions and 2 deletions
@@ -164,6 +164,11 @@ object BackupPayload {
if (out.length() > 0) root.put("storyArchive", out)
}
internal.optJSONObject("drafts")?.takeIf { it.length() > 0 }?.let { root.put("drafts", it) }
// Carried whole, `at` and `by` included, though this app shows neither.
// Dropping a field an engine cannot use turns a backup into a lossy
// hop: restore an iPhone through an Android phone and the pins come
// back stripped of who pinned them and when.
internal.optJSONObject("pins")?.takeIf { it.length() > 0 }?.let { root.put("pins", 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)
@@ -362,6 +367,7 @@ object BackupPayload {
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("pins")?.takeIf { it.length() > 0 }?.let { out.put("pins", 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)
@@ -74,6 +74,7 @@ class Vault(
private const val STORY_MUTES = "story-mutes"
private const val STORY_ARCHIVE = "story-archive"
private const val STORY_REACTIONS = "story-reactions"
private const val PINNED_PREFIX = "pinned-"
private const val DISAPPEAR = "disappear"
private const val OFF_RECORD = "offrecord"
private const val POLLS = "polls"
@@ -1370,6 +1371,7 @@ class Vault(
// 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) } }
.also { p -> pins().takeIf { it.length() > 0 }?.let { p.put("pins", 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
@@ -1559,6 +1561,15 @@ class Vault(
payload.optJSONObject("polls")?.let {
if (readJson(POLLS) == null) writeJson(POLLS, it)
}
// Gaps only: a pin set on THIS phone is the current one.
payload.optJSONObject("pins")?.let { imported ->
val fresh = JSONObject()
for (thread in imported.keys()) {
if (pinnedRecord(thread) != null) continue
imported.optJSONObject(thread)?.let { fresh.put(thread, it) }
}
if (fresh.length() > 0) savePins(fresh)
}
mergePresenceTokens(payload)
return ImportCounts(newMessages, newStories)
@@ -1750,13 +1761,23 @@ class Vault(
// -- Pinned message (§6.19) ----------------------------------------------.
private fun pinnedName(threadId: String) = "pinned-$threadId"
private fun pinnedName(threadId: String) = PINNED_PREFIX + threadId
/**
* The message pinned in a thread, or null.
*/
fun pinnedMessage(threadId: String): String? = readJson(pinnedName(threadId))?.optString("mid")?.takeIf { it.isNotEmpty() }
/**
* The whole pin record, `mid` plus whatever else travelled with it.
*
* §16's pin carries when it was pinned and by whom, and this app shows
* neither. It still keeps them, because dropping a field an engine cannot
* use turns a backup into a lossy hop: restore an iPhone through an
* Android phone and the pins come back stripped.
*/
fun pinnedRecord(threadId: String): JSONObject? = readJson(pinnedName(threadId))
/** Pin a message, or clear the pin with null. */
fun savePinnedMessage(
threadId: String,
@@ -1765,7 +1786,29 @@ class Vault(
if (messageId.isNullOrEmpty()) {
storage.delete(pinnedName(threadId))
} else {
writeJson(pinnedName(threadId), JSONObject().put("mid", messageId))
// Whatever else the record carried stays: see `pinnedRecord`.
val record = readJson(pinnedName(threadId)) ?: JSONObject()
writeJson(pinnedName(threadId), record.put("mid", messageId))
}
}
/** Every pinned thread, as the export carries them. */
fun pins(): JSONObject {
val out = JSONObject()
for (name in storage.names()) {
if (!name.startsWith(PINNED_PREFIX)) continue
val record = readJson(name) ?: continue
if (record.optString("mid").isEmpty()) continue
out.put(name.removePrefix(PINNED_PREFIX), record)
}
return out
}
fun savePins(pins: JSONObject) {
for (thread in pins.keys()) {
val record = pins.optJSONObject(thread) ?: continue
if (record.optString("mid").isEmpty()) continue
writeJson(pinnedName(thread), record)
}
}