Per request: re-enable allowBackup but scope it to ONE file via include-rules — files/gbackup/latest.json.age, an age-encrypted export written only while a backup passphrase is set (never plaintext to Google; DB/prefs stay excluded). On a new device the OS restores the blob, the Today screen detects an empty DB + present blob and offers 'Gjenopprett' — which demands the passphrase typed, since the Keystore key never migrates. Clearing the passphrase deletes the blob. scrypt work factor dropped 18→15: age's desktop default needs a 256 MiB working set and OOM-crashed on-device (found the hard way); 15 = 32 MiB, still real brute-force cost, and largeHeap covers decrypting foreign files made at 18. Verified by instrumented round-trip tests (write → empty DB → restore; wrong passphrase keeps DB empty; cleared passphrase removes blob) — UI automation couldn't drive the multi-field dialog reliably, so the proof lives in the test instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
5.1 KiB
Kotlin
133 lines
5.1 KiB
Kotlin
package no.naiv.meddetsamme.backup
|
|
|
|
import android.content.Context
|
|
import java.io.File
|
|
import java.time.Instant
|
|
import java.time.ZoneOffset
|
|
import java.time.format.DateTimeFormatter
|
|
import no.naiv.meddetsamme.data.MedDatabase
|
|
import no.naiv.meddetsamme.settings.SettingsStore
|
|
|
|
/**
|
|
* The one entry point for export, import and auto-backup — all three share
|
|
* [BackupSerializer] and [BackupCrypto] by construction (brief decision #6).
|
|
*/
|
|
class BackupManager(private val context: Context) {
|
|
|
|
private val db get() = MedDatabase.get(context)
|
|
|
|
/** Plaintext JSON export (caller decides whether/how to encrypt). */
|
|
suspend fun exportJson(): String {
|
|
val dao = db.backupDao()
|
|
return BackupSerializer.export(
|
|
dao.allInventoryItems(),
|
|
dao.allMedications(),
|
|
dao.allDoseTimes(),
|
|
dao.allDoseLogs(),
|
|
)
|
|
}
|
|
|
|
/** Export encrypted with a *typed* passphrase (never stored — manual path). */
|
|
suspend fun exportEncrypted(passphrase: CharArray): ByteArray =
|
|
BackupCrypto.all().first().encrypt(exportJson().toByteArray(Charsets.UTF_8), passphrase)
|
|
|
|
/**
|
|
* Import from file content: encrypted (format auto-detected) or plain JSON.
|
|
* Replace-all; the caller MUST re-arm alarms afterwards.
|
|
*/
|
|
suspend fun import(data: ByteArray, passphrase: CharArray?): Int {
|
|
val crypto = BackupCrypto.detect(data)
|
|
val json = when {
|
|
crypto != null -> {
|
|
requireNotNull(passphrase) { "Denne filen er kryptert — passordfrase kreves" }
|
|
crypto.decrypt(data, passphrase).toString(Charsets.UTF_8)
|
|
}
|
|
else -> data.toString(Charsets.UTF_8)
|
|
}
|
|
val file = BackupSerializer.parse(json)
|
|
BackupSerializer.restore(db, file)
|
|
return file.medications.size
|
|
}
|
|
|
|
/**
|
|
* The one file Google Auto Backup is allowed to carry (see
|
|
* data_extraction_rules.xml): an age-encrypted export, written only while
|
|
* a passphrase is set — never plaintext to Google. Restoring it on a new
|
|
* device requires typing the passphrase; Keystore keys don't migrate.
|
|
*/
|
|
suspend fun writeCloudBlob() {
|
|
val passphrase = SettingsStore(context).autoBackupPassphrase
|
|
val file = cloudBlobFile(context)
|
|
if (passphrase.isNullOrEmpty()) {
|
|
// Passphrase cleared → stop offering data to Google at all.
|
|
if (file.exists()) {
|
|
file.delete()
|
|
android.app.backup.BackupManager(context).dataChanged()
|
|
}
|
|
return
|
|
}
|
|
val crypto = BackupCrypto.all().first()
|
|
val bytes = crypto.encrypt(exportJson().toByteArray(Charsets.UTF_8), passphrase.toCharArray())
|
|
file.parentFile?.mkdirs()
|
|
file.writeBytes(bytes)
|
|
// Hint the OS scheduler that there's fresh data to pick up.
|
|
android.app.backup.BackupManager(context).dataChanged()
|
|
}
|
|
|
|
/**
|
|
* Encrypted blob restored by Google onto a device whose database is empty
|
|
* — the "new phone" signal. Null otherwise.
|
|
*/
|
|
suspend fun pendingCloudRestore(): File? {
|
|
val file = cloudBlobFile(context)
|
|
if (!file.exists()) return null
|
|
val dao = db.backupDao()
|
|
val empty = dao.allInventoryItems().isEmpty() && dao.allMedications().isEmpty()
|
|
return if (empty) file else null
|
|
}
|
|
|
|
companion object {
|
|
fun cloudBlobFile(context: Context): File =
|
|
File(context.filesDir, "gbackup/latest.json.age")
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
* otherwise uploads plaintext JSON by explicit user choice.
|
|
* Distinct timestamped objects; retention is the bucket's lifecycle rule's
|
|
* problem, not ours.
|
|
*/
|
|
suspend fun runAutoBackup(): String {
|
|
val settings = SettingsStore(context)
|
|
val s3 = checkNotNull(settings.s3Client()) { "Backup er ikke konfigurert" }
|
|
|
|
val json = exportJson().toByteArray(Charsets.UTF_8)
|
|
val passphrase = settings.autoBackupPassphrase
|
|
val (body, suffix) = if (passphrase.isNullOrEmpty()) {
|
|
json to "json"
|
|
} else {
|
|
val crypto = BackupCrypto.all().first()
|
|
crypto.encrypt(json, passphrase.toCharArray()) to "json.${crypto.formatId}"
|
|
}
|
|
|
|
val stamp = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")
|
|
.withZone(ZoneOffset.UTC).format(Instant.now())
|
|
val key = "meddetsamme/backup-$stamp.$suffix"
|
|
s3.put(key, body)
|
|
return key
|
|
}
|
|
}
|