Android writes and reads the shared backup format
Section 16 pins the envelope and says nothing about what is sealed, so each app sealed its own storage verbatim and stamped the file with the platform that wrote it. Each then refused the other's, in as many words: "import it on a device of the same kind". Somebody moving between an Android phone and an iPhone lost every conversation they had. The iPhone half landed first. This is the other half. `BackupPayload` translates rather than dumps, which is the entire point. This app stores a message under the keys its wire format uses (`secs`, `fwd`, `rx`), keeps one-to-one and group threads in one map under prefixed ids, hangs the attachment kind beside `att` rather than inside it, abbreviates the sizes, and holds expiries in unix seconds. The shared shape spells all of that out and counts in milliseconds. Exporting storage as-is is what made the two formats incompatible in the first place, so a copy would have reproduced the bug with more steps. Federated threads are the one thing that does not fit. This app keys them by address and the iPhone's history is keyed by numeric user id, with a decoder that skips anything else, so an address left in `threads` would be dropped in silence. They travel under `addrThreads` instead: an iPhone ignores the key, an Android phone gets them back, and nothing disappears without somebody having decided it should. Import tells the two formats apart by SHAPE, not by file name, because a name is whatever the person saving it chose. Section 16 announces itself with `alg` and `ct`; the older export announces itself with `magic`. A wrong passphrase on a section 16 file reports a wrong passphrase and does NOT fall through to the legacy reader, which would have answered "that file is not a Flamenet history export" for a file that is one and sent somebody hunting for corruption instead of a typo. Nothing writes the old format any more, and everything still reads it. Seventeen tests: the shared side against the key names the iPhone writes, the round trip back into this app's own storage shape, the unit conversions in both directions, and a payload transcribed from the iPhone's encoder. That last one is the weak point and the test says so: it proves this app reads the shape the iPhone is BELIEVED to write. Only a vector both apps are built against proves they agree, and `backup-v1.json` pins the envelope alone, which is exactly how the two shapes drifted apart. Full suite: 1271 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3bcb7d5900
commit
5a5e41bfa2
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.HistoryExport
|
||||
import io.flamenet.e2e.BackupPayload
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -71,9 +71,9 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
Text(
|
||||
"Your history lives only on this phone, the server keeps none, so a " +
|
||||
"new phone starts empty. An export is sealed with a passphrase you " +
|
||||
"choose and can be imported on another Android phone signed into " +
|
||||
"this account. Lose the passphrase and the backup cannot be " +
|
||||
"opened, by anyone.",
|
||||
"choose and opens on any phone signed into this account, Android " +
|
||||
"or iPhone. Lose the passphrase and the backup cannot be opened, " +
|
||||
"by anyone.",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
@@ -124,7 +124,9 @@ fun HistoryBackupSection(model: MessengerViewModel) {
|
||||
} else {
|
||||
pendingExport = bytes
|
||||
val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
saveLauncher.launch("flamenet-history-$stamp.${HistoryExport.FILE_EXTENSION}")
|
||||
// The shared extension. Backups made by older versions
|
||||
// still open; this is only what new ones are called.
|
||||
saveLauncher.launch("flamenet-history-$stamp.${BackupPayload.FILE_EXTENSION}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import io.flamenet.client.RelayAccounts
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.client.RelayException
|
||||
import io.flamenet.e2e.AccountKdf
|
||||
import io.flamenet.e2e.Backup
|
||||
import io.flamenet.e2e.BackupPayload
|
||||
import io.flamenet.e2e.ContactCard
|
||||
import io.flamenet.e2e.Content
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
@@ -2641,7 +2643,15 @@ class MessengerViewModel(
|
||||
*/
|
||||
suspend fun exportHistory(passphrase: CharArray): ByteArray? =
|
||||
withContext(Dispatchers.Default) {
|
||||
vault?.let { HistoryExport.seal(it.exportPayload(), passphrase) }
|
||||
vault?.let {
|
||||
// The shared section 16 format, so this file opens on an
|
||||
// iPhone. The older Android-only export is still READ, below,
|
||||
// but nothing writes one any more.
|
||||
val payload = BackupPayload.toShared(it.exportPayload())
|
||||
Backup.encodeFile(
|
||||
Backup.create(payload.toString().toByteArray(Charsets.UTF_8), String(passphrase)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge a backup file in. Returns how much of it was actually new. */
|
||||
@@ -2655,12 +2665,40 @@ class MessengerViewModel(
|
||||
?: return@withContext Result.failure(IllegalStateException("Sign in first."))
|
||||
runCatching {
|
||||
v.mergeImportedPayload(
|
||||
HistoryExport.open(bytes, passphrase),
|
||||
openBackup(bytes, passphrase),
|
||||
nowSeconds = DeviceClock.expirySeconds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a backup of either kind, told apart by SHAPE rather than by name.
|
||||
*
|
||||
* A file name is whatever the person saving it chose, and the two formats
|
||||
* are not distinguishable by extension once somebody has renamed one. The
|
||||
* section 16 envelope announces itself with `alg` and `ct`; the older
|
||||
* Android-only export announces itself with `magic`. So the shape decides,
|
||||
* and somebody restoring an old backup never has to know which they have.
|
||||
*
|
||||
* The section 16 attempt goes first because it is what this app writes now.
|
||||
* A wrong passphrase must NOT fall through to the legacy reader: it would
|
||||
* report "not a Flamenet history export" for a file that is one, and send
|
||||
* somebody looking for a corrupted file instead of a typo.
|
||||
*/
|
||||
private fun openBackup(
|
||||
bytes: ByteArray,
|
||||
passphrase: CharArray,
|
||||
): org.json.JSONObject {
|
||||
val envelope = runCatching { Backup.decodeFile(bytes) }.getOrNull()
|
||||
if (envelope != null) {
|
||||
val plain =
|
||||
runCatching { Backup.open(envelope, String(passphrase)) }
|
||||
.getOrElse { throw HistoryExport.WrongPassphrase() }
|
||||
return BackupPayload.fromShared(org.json.JSONObject(String(plain, Charsets.UTF_8)))
|
||||
}
|
||||
return HistoryExport.open(bytes, passphrase)
|
||||
}
|
||||
|
||||
// -- Stories (§6.6) ------------------------------------------------------.
|
||||
|
||||
fun refreshStories() =
|
||||
|
||||
Reference in New Issue
Block a user