diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index f6109e1..b1ea2f8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -660,6 +660,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { RegisteredDevicesSection(model, phase) } + SectionBand() + SettingsSection { HistoryBackupSection(model) } + SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt new file mode 100644 index 0000000..86ff598 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/HistoryBackup.kt @@ -0,0 +1,230 @@ +package io.flamenet.messenger.app + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +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 kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Export and import of encrypted history backups (D4). + * + * The relay keeps no history, so a new phone starts empty — this is the + * remedy, and the passphrase is deliberately the only key: the app stores + * nothing that could open the file, so neither can anyone who takes the file. + */ +@Composable +fun HistoryBackupSection(model: MessengerViewModel) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var showExportDialog by remember { mutableStateOf(false) } + var pendingExport by remember { mutableStateOf(null) } + var pendingImport by remember { mutableStateOf(null) } + var working by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri -> + val bytes = pendingExport + pendingExport = null + if (uri == null || bytes == null) return@rememberLauncherForActivityResult + status = runCatching { + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: error("nothing to write to") + }.fold( + onSuccess = { "Backup saved. Keep the passphrase — it is the only key." }, + onFailure = { "Couldn't write the file: ${it.message}" }, + ) + } + + val openLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) pendingImport = uri } + + Text("History backup", style = MaterialTheme.typography.titleMedium) + 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.", + fontSize = 12.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.sm)) + Row { + OutlinedButton( + onClick = { showExportDialog = true }, + enabled = !working, + modifier = Modifier.testTag("export-history"), + ) { Text("Export…") } + Spacer(Modifier.width(Space.sm)) + OutlinedButton( + // SAF filters on MIME, and a custom extension has none — offer + // everything and let the crypto refuse what isn't a backup. + onClick = { openLauncher.launch(arrayOf("*/*")) }, + enabled = !working, + modifier = Modifier.testTag("import-history"), + ) { Text("Import…") } + } + status?.let { + Spacer(Modifier.height(Space.xs)) + Text(it, fontSize = 12.sp, color = Palette.SecondaryText, + modifier = Modifier.testTag("backup-status")) + } + + if (showExportDialog) { + PassphraseDialog( + title = "Export history", + explainer = "This passphrase is the only key to the backup. It isn't " + + "stored anywhere — a forgotten passphrase means the backup can " + + "never be opened.", + requireRepeat = true, + busy = working, + onDismiss = { if (!working) showExportDialog = false }, + ) { phrase -> + working = true + scope.launch { + val bytes = model.exportHistory(phrase.toCharArray()) + working = false + showExportDialog = false + if (bytes == null) { + status = "Sign in first." + } else { + pendingExport = bytes + val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + saveLauncher.launch("flamenet-history-$stamp.${HistoryExport.FILE_EXTENSION}") + } + } + } + } + + pendingImport?.let { uri -> + PassphraseDialog( + title = "Import history", + explainer = "Anything already on this phone stays — the backup is " + + "merged in, never a replacement.", + requireRepeat = false, + busy = working, + onDismiss = { if (!working) pendingImport = null }, + ) { phrase -> + working = true + scope.launch { + val bytes = runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes == null) { + working = false + pendingImport = null + status = "Couldn't read that file." + return@launch + } + val outcome = model.importHistory(bytes, phrase.toCharArray()) + working = false + pendingImport = null + status = outcome.fold( + onSuccess = { counts -> + if (counts.messages + counts.stories == 0) + "Backup opened — everything in it was already here." + else + "Imported ${counts.messages} message${if (counts.messages == 1) "" else "s"}" + + " and ${counts.stories} " + + if (counts.stories == 1) "story." else "stories." + }, + onFailure = { it.message ?: "That backup couldn't be opened." }, + ) + } + } + } +} + +@Composable +private fun PassphraseDialog( + title: String, + explainer: String, + requireRepeat: Boolean, + busy: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var phrase by remember { mutableStateOf("") } + var repeat by remember { mutableStateOf("") } + val mismatch = requireRepeat && repeat.isNotEmpty() && repeat != phrase + val ready = phrase.length >= 6 && (!requireRepeat || repeat == phrase) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + androidx.compose.foundation.layout.Column { + Text(explainer, fontSize = 12.sp, color = Palette.SecondaryText) + Spacer(Modifier.height(Space.sm)) + OutlinedTextField( + value = phrase, + onValueChange = { phrase = it }, + placeholder = { Text("Passphrase", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth().testTag("backup-passphrase"), + ) + if (requireRepeat) { + Spacer(Modifier.height(Space.xs)) + OutlinedTextField( + value = repeat, + onValueChange = { repeat = it }, + placeholder = { Text("Repeat passphrase", fontSize = 14.sp) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = mismatch, + modifier = Modifier.fillMaxWidth().testTag("backup-passphrase-repeat"), + ) + if (mismatch) { + Text("The two passphrases don't match yet.", + fontSize = 12.sp, color = MaterialTheme.colorScheme.error) + } + } + if (phrase.isNotEmpty() && phrase.length < 6) { + Text("Use at least 6 characters.", + fontSize = 12.sp, color = Palette.SecondaryText) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(phrase) }, + enabled = ready && !busy, + modifier = Modifier.testTag("backup-confirm"), + ) { Text(if (busy) "Working…" else "Continue") } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index c15b1e9..efe4222 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -12,6 +12,7 @@ import io.flamenet.e2e.AccountKdf import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.FNID +import io.flamenet.e2e.HistoryExport import io.flamenet.e2e.Vault import kotlinx.coroutines.Dispatchers import io.flamenet.client.IdentityTrust @@ -1251,6 +1252,31 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + // -- History backup (D4) ------------------------------------------------- + + /** + * The vault's history, sealed under a passphrase the user chose. + * + * Off the main thread on purpose: the KDF runs 600k rounds, which is the + * point of it, and would freeze the UI for a visible beat. + */ + suspend fun exportHistory(passphrase: CharArray): ByteArray? = + withContext(Dispatchers.Default) { + vault?.let { HistoryExport.seal(it.exportPayload(), passphrase) } + } + + /** Merge a backup file in. Returns how much of it was actually new. */ + suspend fun importHistory(bytes: ByteArray, passphrase: CharArray): Result = + withContext(Dispatchers.Default) { + val v = vault + ?: return@withContext Result.failure(IllegalStateException("Sign in first.")) + runCatching { + v.mergeImportedPayload( + HistoryExport.open(bytes, passphrase), + nowSeconds = System.currentTimeMillis() / 1000.0) + } + } + // -- Stories (§6.6) ------------------------------------------------------ fun refreshStories() = viewModelScope.launch { diff --git a/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt b/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt new file mode 100644 index 0000000..8100c8e --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/HistoryExport.kt @@ -0,0 +1,93 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +/** + * A history backup the platform cannot read. + * + * The relay keeps no history — that is the product's whole promise — which + * means a new device genuinely starts empty. This is the designed remedy: the + * vault's history, re-sealed under a key derived from a passphrase the user + * chooses, so the file is safe to park anywhere and useless without the words. + * + * Same envelope as the iOS app (a JSON header naming the KDF around one + * AES-256-GCM box), but the v1 payload is platform-shaped, so the envelope is + * platform-tagged and a mismatched import is refused with an honest message + * rather than half-parsed. + * + * ⚠️ The passphrase is the only key. It is not stored anywhere — not in the + * Keystore, not in the file. A forgotten passphrase means the backup is gone, + * and that is the design, not a gap. + */ +object HistoryExport { + + const val FILE_EXTENSION = "fnhistory" + + /** High on purpose: runs once per export, prices out billions of guesses. */ + private const val ITERATIONS = 600_000 + private const val SALT_LEN = 16 + private const val NONCE_LEN = 12 + private const val TAG_BITS = 128 + + class NotAnExport : Exception("That file is not a Flamenet history export.") + class WrongPlatform(val platform: String) : + Exception("This backup was made on ${if (platform == "ios") "iPhone" else platform} — import it on a device of the same kind.") + class WrongPassphrase : Exception("That passphrase doesn't open this backup.") + + fun seal(payload: JSONObject, passphrase: CharArray): ByteArray { + val salt = ByteArray(SALT_LEN).also(SecureRandom()::nextBytes) + val key = derive(passphrase, salt, ITERATIONS) + val nonce = ByteArray(NONCE_LEN).also(SecureRandom()::nextBytes) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce)) + val sealed = cipher.doFinal(payload.toString().toByteArray(Charsets.UTF_8)) + val envelope = JSONObject() + .put("magic", "fnhistory") + .put("v", 1) + .put("platform", "android") + .put("kdf", JSONObject() + .put("alg", "pbkdf2-sha256") + .put("salt", Bytes.b64(salt)) + .put("iters", ITERATIONS)) + .put("box", Bytes.b64(nonce + sealed)) + return envelope.toString().toByteArray(Charsets.UTF_8) + } + + fun open(bytes: ByteArray, passphrase: CharArray): JSONObject { + val envelope = runCatching { JSONObject(String(bytes, Charsets.UTF_8)) } + .getOrNull() ?: throw NotAnExport() + val kdf = envelope.optJSONObject("kdf") + if (envelope.optString("magic") != "fnhistory" || envelope.optInt("v") != 1 || + kdf == null || kdf.optString("alg") != "pbkdf2-sha256" + ) throw NotAnExport() + val platform = envelope.optString("platform") + if (platform != "android") throw WrongPlatform(platform) + val salt = runCatching { Bytes.unb64(kdf.getString("salt")) }.getOrNull() + val combined = runCatching { Bytes.unb64(envelope.getString("box")) }.getOrNull() + if (salt == null || combined == null || combined.size <= NONCE_LEN) throw NotAnExport() + // The count comes from the file so an old export still opens after the + // default rises — clamped, because a hostile file naming ten billion + // rounds would wedge the import forever. + val iters = kdf.optInt("iters", ITERATIONS).coerceIn(100_000, 2_000_000) + val key = derive(passphrase, salt, iters) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_BITS, combined.copyOfRange(0, NONCE_LEN))) + val plain = runCatching { + cipher.doFinal(combined.copyOfRange(NONCE_LEN, combined.size)) + }.getOrNull() ?: throw WrongPassphrase() + return runCatching { JSONObject(String(plain, Charsets.UTF_8)) } + .getOrNull() ?: throw NotAnExport() + } + + private fun derive(passphrase: CharArray, salt: ByteArray, iterations: Int): ByteArray = + SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(PBEKeySpec(passphrase, salt, iterations, 256)).encoded +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 0b10e41..1498b2b 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -576,6 +576,112 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun replaceHistory(threadId: String, messages: JSONArray) = writeJson(historyName(threadId), JSONObject().put("messages", messages)) + // -- History backup (D4) ------------------------------------------------- + + /** + * Everything a history backup carries, as one JSON value. + * + * The outbox is deliberately left out: a pending send belongs to the + * device that queued it, and importing one elsewhere would either send a + * duplicate or strand a ghost that can never be flushed. Sessions, trust + * and keys are left out too — a backup restores what was said, never who + * this device is. + */ + fun exportPayload(): JSONObject { + val threadsObj = JSONObject() + threads().forEach { tid -> threadsObj.put(tid, history(tid)) } + return JSONObject() + .put("threads", threadsObj) + .put("callLog", callLog()) + .also { p -> readJson(STORIES)?.let { p.put("stories", it) } } + } + + data class ImportCounts(val messages: Int, val stories: Int) + + /** + * Merge a backup into what this device already has. Returns how much of + * it was actually new. + * + * Merge, never replace: the device has been receiving mail since it + * signed in, and an import that overwrote those rows would trade old + * history for new. Lines deduplicate by content (mine + timestamp + text + * + attachment), stories by their real id, calls by peer + timestamp — + * and the same backup imported twice adds nothing the second time. + */ + fun mergeImportedPayload(payload: JSONObject, nowSeconds: Double): ImportCounts { + var newMessages = 0 + val threadsObj = payload.optJSONObject("threads") ?: JSONObject() + for (tid in threadsObj.keys()) { + val incoming = threadsObj.optJSONArray(tid) ?: continue + val current = history(tid) + val seen = HashSet() + for (i in 0 until current.length()) seen.add(lineKey(current.getJSONObject(i))) + val fresh = ArrayList() + for (i in 0 until incoming.length()) { + val m = incoming.optJSONObject(i) ?: continue + if (seen.add(lineKey(m))) fresh.add(m) + } + if (fresh.isEmpty()) continue + newMessages += fresh.size + val merged = ArrayList(current.length() + fresh.size) + for (i in 0 until current.length()) merged.add(current.getJSONObject(i)) + merged.addAll(fresh) + merged.sortBy { it.optLong("at") } + replaceHistory(tid, JSONArray().also { arr -> merged.forEach(arr::put) }) + } + + var newStories = 0 + val importedStories = payload.optJSONObject("stories")?.optJSONArray("items") + if (importedStories != null && importedStories.length() > 0) { + val current = readJson(STORIES)?.optJSONArray("items") ?: JSONArray() + val known = HashSet() + for (i in 0 until current.length()) known.add(current.getJSONObject(i).optString("id")) + for (i in 0 until importedStories.length()) { + val s = importedStories.optJSONObject(i) ?: continue + // An expired story must not sneak back in through a backup — + // the expiry is a promise about the bytes leaving the device. + val exp = s.optJSONObject("ref")?.optDouble("exp", 0.0) ?: 0.0 + val active = exp == 0.0 || nowSeconds < exp + if (active && s.optString("id").isNotEmpty() && known.add(s.optString("id"))) { + current.put(s) + newStories++ + } + } + writeJson(STORIES, JSONObject().put("items", current)) + } + + val importedCalls = payload.optJSONArray("callLog") + if (importedCalls != null && importedCalls.length() > 0) { + val current = callLog() + val known = HashSet() + for (i in 0 until current.length()) { + val c = current.getJSONObject(i) + known.add("${c.optLong("peer")}|${c.optLong("at")}") + } + var changed = false + for (i in 0 until importedCalls.length()) { + val c = importedCalls.optJSONObject(i) ?: continue + if (known.add("${c.optLong("peer")}|${c.optLong("at")}")) { + current.put(c) + changed = true + } + } + if (changed) { + val sorted = ArrayList(current.length()) + for (i in 0 until current.length()) sorted.add(current.getJSONObject(i)) + sorted.sortBy { it.optLong("at") } + saveCallLog(JSONArray().also { arr -> sorted.forEach(arr::put) }) + } + } + + return ImportCounts(newMessages, newStories) + } + + /** 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")}|" + + (m.optJSONObject("att")?.optString("id") ?: "") + /** Every thread this device holds, group or person. */ fun threads(): List = storage.names().filter { it.startsWith(HISTORY_PREFIX) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt new file mode 100644 index 0000000..53e2be9 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/HistoryExportTest.kt @@ -0,0 +1,141 @@ +package io.flamenet.e2e + +import org.json.JSONArray +import org.json.JSONObject +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The history backup opens only with the right words, and merging never loses + * what a device already has. The dedupe rules are the part worth guarding: + * the same backup imported twice must add nothing the second time. + * + * ⚠️ These run on the JVM. The crypto here (PBKDF2, AES-GCM via the platform + * `Cipher`) is deliberately the same JCA surface the vault already exercises + * on Android — but per the Conscrypt rule, the on-device instrumentation + * suite is what proves the Android side. + */ +class HistoryExportTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun vault() = Vault(MapStorage(), key()) + + private fun line(text: String, mine: Boolean, at: Long): JSONObject = + JSONObject().put("author", 0).put("text", text).put("mine", mine).put("at", at) + + private fun populated(): Vault { + val v = vault() + v.append("user:42", line("packing now", true, 1_756_400_000_000)) + v.append("user:42", line("bring the charger", false, 1_756_400_060_000)) + v.append("group:g1", line("meeting moved", false, 1_756_400_120_000)) + v.saveCallLog(JSONArray().put(JSONObject() + .put("peer", 42).put("in", true).put("ok", true).put("at", 1_756_400_500_000))) + return v + } + + // -- Sealing -------------------------------------------------------------- + + @Test + fun `a backup round trips with the right passphrase`() { + val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray()) + val back = HistoryExport.open(sealed, "correct horse".toCharArray()) + assertEquals(2, back.getJSONObject("threads").getJSONArray("user:42").length()) + assertEquals(1, back.getJSONObject("threads").getJSONArray("group:g1").length()) + assertEquals(1, back.getJSONArray("callLog").length()) + } + + @Test + fun `the wrong passphrase is refused as exactly that`() { + val sealed = HistoryExport.seal(populated().exportPayload(), "correct horse".toCharArray()) + assertFailsWith { + HistoryExport.open(sealed, "wrong pony".toCharArray()) + } + } + + @Test + fun `garbage is not an export`() { + assertFailsWith { + HistoryExport.open("not json".toByteArray(), "x".toCharArray()) + } + } + + @Test + fun `an iPhone backup is refused honestly`() { + val sealed = HistoryExport.seal(populated().exportPayload(), "pw-123456".toCharArray()) + val doctored = JSONObject(String(sealed)).put("platform", "ios") + assertFailsWith { + HistoryExport.open(doctored.toString().toByteArray(), "pw-123456".toCharArray()) + } + } + + @Test + fun `the outbox never leaves the device`() { + val v = populated() + v.saveOutbox(JSONArray().put(JSONObject().put("to", 42).put("text", "pending"))) + assertTrue(!v.exportPayload().has("outbox")) + } + + // -- Merging -------------------------------------------------------------- + + @Test + fun `merging into an empty device takes everything`() { + val fresh = vault() + val counts = fresh.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0) + assertEquals(3, counts.messages) + assertEquals(2, fresh.history("user:42").length()) + assertEquals(1, fresh.callLog().length()) + } + + @Test + fun `merging keeps what the device already has`() { + val device = vault() + device.append("user:42", line("sent after the export", false, 1_756_500_000_000)) + device.mergeImportedPayload(populated().exportPayload(), nowSeconds = 0.0) + val texts = device.history("user:42").let { arr -> + (0 until arr.length()).map { arr.getJSONObject(it).getString("text") } + } + assertEquals(listOf("packing now", "bring the charger", "sent after the export"), texts) + } + + @Test + fun `the same backup twice adds nothing the second time`() { + val device = vault() + val payload = populated().exportPayload() + device.mergeImportedPayload(payload, nowSeconds = 0.0) + val second = device.mergeImportedPayload(payload, nowSeconds = 0.0) + assertEquals(0, second.messages) + assertEquals(0, second.stories) + assertEquals(2, device.history("user:42").length()) + assertEquals(1, device.callLog().length()) + } + + @Test + fun `an expired story does not sneak back in through a backup`() { + val source = vault() + val items = JSONArray() + .put(JSONObject().put("id", "story-live").put("author", 42).put("at", 5) + .put("ref", JSONObject().put("exp", 0.0))) + .put(JSONObject().put("id", "story-dead").put("author", 42).put("at", 6) + .put("ref", JSONObject().put("exp", 100.0))) + // Write through the raw record shape the vault itself uses. + val payload = source.exportPayload().put("stories", JSONObject().put("items", items)) + + val device = vault() + val counts = device.mergeImportedPayload(payload, nowSeconds = 999.0) + assertEquals(1, counts.stories) + } +}