From 58ced61cd8411ed3736763250c0a81f5afe77cfd Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:51:04 +0100 Subject: [PATCH 1/3] Make the daily backup write crash-safe The old write deleted today's backup before the new one was on disk (or truncated it in place), so a crash or full storage mid-write could lose the only copy. Now the content goes to a temp document first (.tmp.json, never listed or pruned), the old file is removed only once the temp is complete, and the temp is renamed into place, with a copy fallback for providers that cannot rename. A leftover temp from an interrupted write is recovered or cleaned up on the next write. Co-Authored-By: Claude Opus 5.5 --- .../dev/susiee/dosekeeper/BackupStore.kt | 89 +++++++++++++++---- test/backup_test.dart | 1 + 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt index 22db795..c13836d 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt @@ -180,30 +180,87 @@ object BackupStore { } /** - * Writes [content] as [name], replacing that file if it exists. Opened with "wt" - * (truncate): plain "w" does not truncate on every provider, and a shorter backup - * written over a longer one would leave trailing garbage -- a corrupt backup that - * only shows up on the day it's needed. + * Writes [content] as [name], replacing that file if it exists -- crash-safe: + * the content goes to a fresh temp document first (`.tmp.json`, which + * Backup.isBackupFileName does not match, so it is never listed or pruned), and + * today's existing file is deleted only once the temp is complete. The temp then + * takes its place by rename (or copy where a provider cannot rename). Dying + * before the delete leaves the old file untouched; dying after it leaves the + * complete temp, which the next write installs or cleans up. A fresh empty + * document has no stale bytes to truncate, so the old "wt"-or-recreate dance + * (and its delete-first data-loss window) is gone entirely. */ fun write(context: Context, name: String, content: String) { val tree = requireFolder(context) val bytes = content.toByteArray(Charsets.UTF_8) - val existing = find(context, tree, name) - val written = existing != null && runCatching { writeTo(context, existing, "wt", bytes) }.isSuccess - if (!written) { - // No file yet, or this provider refuses "wt": start from a fresh, empty file. - existing?.let { DocumentsContract.deleteDocument(context.contentResolver, it) } - val parent = DocumentsContract.buildDocumentUriUsingTree( - tree, - DocumentsContract.getTreeDocumentId(tree), - ) - val created = DocumentsContract.createDocument(context.contentResolver, parent, MIME_JSON, name) - ?: throw FileNotFoundException("Could not create $name") - writeTo(context, created, "w", bytes) + // Ends in .json on purpose: providers append the MIME type's extension to a name + // that lacks it, so "x.json.tmp" would be created as "x.json.tmp.json" and the + // leftover check below would never find it. + val tmpName = name.removeSuffix(".json") + ".tmp.json" + val parent = DocumentsContract.buildDocumentUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree), + ) + + // A leftover temp means a previous write died mid-swap. If today's file + // survived, the temp is junk; if not, the temp is the only complete copy. + find(context, tree, tmpName)?.let { leftover -> + if (find(context, tree, name) != null) { + deleteOrThrow(context, leftover, tmpName) + } else if (!install(context, parent, leftover, name)) { + // Keep the temp: it is the only complete copy of that backup. + throw FileNotFoundException("An interrupted backup of $name could not be recovered") + } + } + + val tmp = DocumentsContract.createDocument(context.contentResolver, parent, MIME_JSON, tmpName) + ?: throw FileNotFoundException("Could not create $tmpName") + try { + writeTo(context, tmp, "w", bytes) + } catch (e: Exception) { + runCatching { DocumentsContract.deleteDocument(context.contentResolver, tmp) } + throw e + } + // The new content is complete on storage; only now may today's file go away. + find(context, tree, name)?.let { deleteOrThrow(context, it, name) } + if (!install(context, parent, tmp, name)) { + throw FileNotFoundException("Could not move $tmpName into place; it still holds the complete backup") } prefs(context).edit().putLong(KEY_LAST_BACKUP_AT, System.currentTimeMillis()).apply() } + /** + * Puts a complete temp document at [name] (which no file currently holds): + * rename, or -- for providers without rename -- copy the bytes into a fresh + * document and delete the temp. On failure nothing is left under [name]. + */ + private fun install(context: Context, parent: Uri, tmp: Uri, name: String): Boolean { + runCatching { DocumentsContract.renameDocument(context.contentResolver, tmp, name) } + .onSuccess { if (it != null) return true } + val created = runCatching { + DocumentsContract.createDocument(context.contentResolver, parent, MIME_JSON, name) + }.getOrNull() ?: return false + val copied = runCatching { + val bytes = context.contentResolver.openInputStream(tmp)?.use { it.readBytes() } + ?: throw FileNotFoundException("Could not read the temp backup") + writeTo(context, created, "w", bytes) + }.isSuccess + if (!copied) { + // Never leave a partial file under a real backup name. + runCatching { DocumentsContract.deleteDocument(context.contentResolver, created) } + return false + } + // Best effort: a surviving temp is cleaned up as stale on the next write. + runCatching { DocumentsContract.deleteDocument(context.contentResolver, tmp) } + return true + } + + private fun deleteOrThrow(context: Context, uri: Uri, name: String) { + if (!DocumentsContract.deleteDocument(context.contentResolver, uri)) { + throw FileNotFoundException("Could not remove $name") + } + } + private fun writeTo(context: Context, doc: Uri, mode: String, bytes: ByteArray) { context.contentResolver.openOutputStream(doc, mode).use { out -> requireNotNull(out) { "Could not open $doc for writing" } diff --git a/test/backup_test.dart b/test/backup_test.dart index f1814c5..29653a8 100644 --- a/test/backup_test.dart +++ b/test/backup_test.dart @@ -194,6 +194,7 @@ void main() { 'holiday.jpg', 'dosekeeper-backup-2026-09-24.json', 'dosekeeper-backup-2026-09-24 (1).json', + 'dosekeeper-backup-2026-09-24.tmp.json', 'dosekeeper-backup-2026-09-23.json', ]), [ From 775c8c4076b4a85514ddae563178766b9a1c05f7 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:55:33 +0100 Subject: [PATCH 2/3] Serialize backup writes and only recover a parseable leftover temp Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/dev/susiee/dosekeeper/BackupStore.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt index c13836d..4726344 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt @@ -190,6 +190,9 @@ object BackupStore { * document has no stale bytes to truncate, so the old "wt"-or-recreate dance * (and its delete-first data-loss window) is gone entirely. */ + // Each channel call runs on its own thread; overlapping writes would interleave + // their create/delete/rename steps, so one write runs at a time. + @Synchronized fun write(context: Context, name: String, content: String) { val tree = requireFolder(context) val bytes = content.toByteArray(Charsets.UTF_8) @@ -202,10 +205,13 @@ object BackupStore { DocumentsContract.getTreeDocumentId(tree), ) - // A leftover temp means a previous write died mid-swap. If today's file - // survived, the temp is junk; if not, the temp is the only complete copy. + // A leftover temp means a previous write died. If today's file survived, the + // temp is junk; if not, the temp is the only copy -- unless the death came + // mid-write on the day's first backup, so install it only if it parses. find(context, tree, tmpName)?.let { leftover -> - if (find(context, tree, name) != null) { + val complete = find(context, tree, name) == null && + runCatching { org.json.JSONObject(readUri(context, leftover)) }.isSuccess + if (!complete) { deleteOrThrow(context, leftover, tmpName) } else if (!install(context, parent, leftover, name)) { // Keep the temp: it is the only complete copy of that backup. From 5308bc578c2b003b95c4e6741913a1c036673c18 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:03:46 +0100 Subject: [PATCH 3/3] Replace a partial backup with a complete leftover temp Co-Authored-By: Claude Opus 5.5 --- .../dev/susiee/dosekeeper/BackupStore.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt index 4726344..d5f9289 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/BackupStore.kt @@ -205,17 +205,19 @@ object BackupStore { DocumentsContract.getTreeDocumentId(tree), ) - // A leftover temp means a previous write died. If today's file survived, the - // temp is junk; if not, the temp is the only copy -- unless the death came - // mid-write on the day's first backup, so install it only if it parses. + // A leftover temp means a previous write died. It replaces today's file only + // when it is complete (parses) and today's file is missing or partial (a + // death mid-copy in install); otherwise it is junk. find(context, tree, tmpName)?.let { leftover -> - val complete = find(context, tree, name) == null && - runCatching { org.json.JSONObject(readUri(context, leftover)) }.isSuccess - if (!complete) { + val dest = find(context, tree, name) + if (!parses(context, leftover) || (dest != null && parses(context, dest))) { deleteOrThrow(context, leftover, tmpName) - } else if (!install(context, parent, leftover, name)) { - // Keep the temp: it is the only complete copy of that backup. - throw FileNotFoundException("An interrupted backup of $name could not be recovered") + } else { + dest?.let { deleteOrThrow(context, it, name) } + if (!install(context, parent, leftover, name)) { + // Keep the temp: it is the only complete copy of that backup. + throw FileNotFoundException("An interrupted backup of $name could not be recovered") + } } } @@ -261,6 +263,9 @@ object BackupStore { return true } + private fun parses(context: Context, uri: Uri) = + runCatching { org.json.JSONObject(readUri(context, uri)) }.isSuccess + private fun deleteOrThrow(context: Context, uri: Uri, name: String) { if (!DocumentsContract.deleteDocument(context.contentResolver, uri)) { throw FileNotFoundException("Could not remove $name")