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
|
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
|
* Unattended backup → S3. Encrypts with the STORED passphrase if one is
|
||||||
* configured (protects against bucket compromise only — see SettingsStore),
|
* configured (protects against bucket compromise only — see SettingsStore),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ class S3Client(
|
||||||
companion object {
|
companion object {
|
||||||
private val defaultClient by lazy { OkHttpClient() }
|
private val defaultClient by lazy { OkHttpClient() }
|
||||||
private val AMZ_DATE = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC)
|
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) :
|
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 {
|
private fun signedRequestBuilder(method: String, key: String, body: ByteArray): Request.Builder {
|
||||||
require(endpoint.startsWith("https://")) { "Endepunkt må være https://" }
|
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 payloadHash = SigV4.hexSha256(body)
|
||||||
val amzDate = AMZ_DATE.format(Instant.now())
|
val amzDate = AMZ_DATE.format(Instant.now())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ fun SettingsScreen(nav: Nav) {
|
||||||
// Export/import passphrase dialogs hold the pending document URI.
|
// Export/import passphrase dialogs hold the pending document URI.
|
||||||
var exportUri by remember { mutableStateOf<Uri?>(null) }
|
var exportUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var importUri 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(
|
val exportPicker = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||||
|
|
@ -189,6 +192,25 @@ fun SettingsScreen(nav: Nav) {
|
||||||
onClick = { importPicker.launch(arrayOf("*/*")) },
|
onClick = { importPicker.launch(arrayOf("*/*")) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Importer fra fil …") }
|
) { 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()
|
HorizontalDivider()
|
||||||
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
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 ->
|
importUri?.let { uri ->
|
||||||
PassphraseDialog(
|
PassphraseDialog(
|
||||||
title = "Importer sikkerhetskopi",
|
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