Restore latest backup straight from the bucket

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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 17:51:13 +02:00
commit 90eb15ca0b
4 changed files with 109 additions and 1 deletions

View file

@ -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<String, ByteArray>? {
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),

View file

@ -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: <Key> elements from a ListBucketResult. */
fun parseListKeys(xml: String): List<String> =
Regex("<Key>([^<]+)</Key>").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<String> {
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())

View file

@ -68,6 +68,9 @@ fun SettingsScreen(nav: Nav) {
// Export/import passphrase dialogs hold the pending document URI.
var exportUri by remember { mutableStateOf<Uri?>(null) }
var importUri by remember { mutableStateOf<Uri?>(null) }
// Downloaded-from-bucket backup awaiting confirmation: key + bytes.
var bucketRestore by remember { mutableStateOf<Pair<String, ByteArray>?>(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",

View file

@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>backups</Name><IsTruncated>false</IsTruncated>
<Contents><Key>meddetsamme/backup-20260610T020000Z.json.age</Key><Size>4321</Size></Contents>
<Contents><Key>meddetsamme/backup-20260611T020000Z.json.age</Key><Size>4400</Size></Contents>
<Contents><Key>other/file.txt</Key><Size>1</Size></Contents>
</ListBucketResult>
""".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("<ListBucketResult></ListBucketResult>").isEmpty())
}
}