From 90eb15ca0b6a7d3346409debd0ab35b7ef460026 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 11 Jun 2026 17:51:13 +0200 Subject: [PATCH] Restore latest backup straight from the bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gains 'Gjenopprett siste backup fra bøtta': lists the bucket (classic query-less ListObjects — an empty canonical query sidesteps a whole class of SigV4 encoding mismatches; keys filtered client-side), picks the lexicographically newest timestamped key, downloads, then runs the existing import path (format auto-detect, passphrase prompt, replace-all, alarm re-arm). Closes the circle: after a device loss, recovery is in-app instead of laptop + age -d. parseListKeys is pure and unit-tested; first ListObjects page only (1000 keys), which the bucket's lifecycle expiration keeps us far below. Co-Authored-By: Claude Fable 5 --- .../naiv/meddetsamme/backup/BackupManager.kt | 13 ++++++ .../no/naiv/meddetsamme/backup/S3Client.kt | 20 ++++++++- .../no/naiv/meddetsamme/ui/SettingsScreen.kt | 44 +++++++++++++++++++ .../naiv/meddetsamme/backup/S3ClientTest.kt | 33 ++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt index c4737ee..1b8f8db 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt @@ -48,6 +48,19 @@ class BackupManager(private val context: Context) { return file.medications.size } + /** + * Newest backup object in the bucket, or null when none exist. Keys embed + * a UTC timestamp, so the lexicographically last key is the newest. + */ + suspend fun fetchLatestBackup(): Pair? { + val settings = SettingsStore(context) + val s3 = checkNotNull(settings.s3Client()) { "Backup er ikke konfigurert" } + val key = s3.listAllKeys() + .filter { it.startsWith("meddetsamme/backup-") } + .maxOrNull() ?: return null + return key to s3.get(key) + } + /** * Unattended backup → S3. Encrypts with the STORED passphrase if one is * configured (protects against bucket compromise only — see SettingsStore), diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt b/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt index 809f4ba..d1caf5a 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/S3Client.kt @@ -27,6 +27,10 @@ class S3Client( companion object { private val defaultClient by lazy { OkHttpClient() } private val AMZ_DATE = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC) + + /** Pure and unit-tested: elements from a ListBucketResult. */ + fun parseListKeys(xml: String): List = + Regex("([^<]+)").findAll(xml).map { it.groupValues[1] }.toList() } class S3Exception(val code: Int, body: String) : @@ -50,9 +54,23 @@ class S3Client( } } + /** + * All keys in the bucket (classic ListObjects, first page only — up to + * 1000 keys; the bucket's lifecycle expiration keeps us far below that). + * Deliberately query-less: an empty canonical query removes a whole class + * of SigV4 encoding mismatches; we filter keys client-side instead. + */ + fun listAllKeys(): List { + val request = signedRequestBuilder("GET", key = "", body = ByteArray(0)).get().build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) throw S3Exception(resp.code, resp.body?.string().orEmpty()) + return parseListKeys(resp.body?.string().orEmpty()) + } + } + private fun signedRequestBuilder(method: String, key: String, body: ByteArray): Request.Builder { require(endpoint.startsWith("https://")) { "Endepunkt må være https://" } - val url = "$endpoint/$bucket/$key".toHttpUrl() + val url = (if (key.isEmpty()) "$endpoint/$bucket" else "$endpoint/$bucket/$key").toHttpUrl() val payloadHash = SigV4.hexSha256(body) val amzDate = AMZ_DATE.format(Instant.now()) diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt index 92193e4..376bde8 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt @@ -68,6 +68,9 @@ fun SettingsScreen(nav: Nav) { // Export/import passphrase dialogs hold the pending document URI. var exportUri by remember { mutableStateOf(null) } var importUri by remember { mutableStateOf(null) } + // Downloaded-from-bucket backup awaiting confirmation: key + bytes. + var bucketRestore by remember { mutableStateOf?>(null) } + var fetchingRestore by remember { mutableStateOf(false) } val exportPicker = rememberLauncherForActivityResult( ActivityResultContracts.CreateDocument("application/octet-stream"), @@ -189,6 +192,25 @@ fun SettingsScreen(nav: Nav) { onClick = { importPicker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth(), ) { Text("Importer fra fil …") } + OutlinedButton( + onClick = { + fetchingRestore = true + scope.launch(Dispatchers.IO) { + try { + val latest = BackupManager(context).fetchLatestBackup() + withContext(Dispatchers.Main) { + if (latest == null) toast("Ingen backuper i bøtta") else bucketRestore = latest + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { toast("Henting feilet: ${e.message}") } + } finally { + withContext(Dispatchers.Main) { fetchingRestore = false } + } + } + }, + enabled = !fetchingRestore, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (fetchingRestore) "Henter …" else "Gjenopprett siste backup fra bøtta …") } HorizontalDivider() Text("Varsler", style = MaterialTheme.typography.titleMedium) @@ -254,6 +276,28 @@ fun SettingsScreen(nav: Nav) { } } + bucketRestore?.let { (key, data) -> + PassphraseDialog( + title = "Gjenopprett fra bøtta", + text = "Fant ${key.removePrefix("meddetsamme/")}. ERSTATTER alle medisiner og all " + + "historikk på enheten. Passordfrase trengs bare for krypterte backuper.", + allowEmpty = true, + onDismiss = { bucketRestore = null }, + ) { passphrase -> + bucketRestore = null + scope.launch(Dispatchers.IO) { + try { + val count = BackupManager(context) + .import(data, passphrase.takeIf { it.isNotEmpty() }) + AlarmScheduler(context).armAll() + withContext(Dispatchers.Main) { toast("Gjenopprettet $count medisiner") } + } catch (e: Exception) { + withContext(Dispatchers.Main) { toast("Gjenoppretting feilet: ${e.message}") } + } + } + } + } + importUri?.let { uri -> PassphraseDialog( title = "Importer sikkerhetskopi", diff --git a/app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt b/app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt new file mode 100644 index 0000000..a9c8758 --- /dev/null +++ b/app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt @@ -0,0 +1,33 @@ +package no.naiv.meddetsamme.backup + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class S3ClientTest { + + @Test + fun `parses keys from a ListBucketResult`() { + val xml = """ + + + backupsfalse + meddetsamme/backup-20260610T020000Z.json.age4321 + meddetsamme/backup-20260611T020000Z.json.age4400 + other/file.txt1 + + """.trimIndent() + val keys = S3Client.parseListKeys(xml) + assertEquals(3, keys.size) + // Timestamped keys sort so that max() is the newest backup. + assertEquals( + "meddetsamme/backup-20260611T020000Z.json.age", + keys.filter { it.startsWith("meddetsamme/backup-") }.max(), + ) + } + + @Test + fun `empty result yields no keys`() { + assertTrue(S3Client.parseListKeys("").isEmpty()) + } +}