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:
parent
861621e657
commit
90eb15ca0b
4 changed files with 109 additions and 1 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
33
app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt
Normal file
33
app/src/test/java/no/naiv/meddetsamme/backup/S3ClientTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue