diff --git a/CLAUDE.md b/CLAUDE.md index 24593d4..2e12abf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,10 @@ The name says the brief: nag me to take the dose *now*. 3. Re-arm every alarm on `BOOT_COMPLETED` + `MY_PACKAGE_REPLACED` (state is wiped then). 4. Surface a one-time battery-optimisation exemption prompt — biggest cause of dropped reminders on aggressive OEMs. -5. No Google Auto Backup (`allowBackup="false"`, excluded from cloud-backup + transfer). +5. Google Auto Backup carries ONLY the app's own age-encrypted blob (`files/gbackup/`, + include-rules in dataExtractionRules/fullBackupContent exclude everything else). + Blob exists only while a passphrase is set; restore on a new device requires typing + it (Keystore never migrates). Amended 2026-06 from a full opt-out, on user request. 6. Own backup: versioned JSON → encrypted → S3-compatible PUT to self-hosted **Garage**, path-style, hand-rolled SigV4. One serializer for export, import, and auto-backup. 7. Crypto target is **age** (passphrase/scrypt) so backups are `age -d`-decryptable and diff --git a/README.md b/README.md index f2ec0f9..4033bce 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,10 @@ ScheduleEngine — pure: next occurrence, daily consumption, Room with exported schemas committed under `app/schemas/`; migrations are proven by instrumented `MigrationTestHelper` tests before they touch real -data. Google Auto Backup is disabled (`allowBackup=false` + full -`dataExtractionRules` opt-out) — health data leaves the device only through -the app's own encrypted backup. +data. Google Auto Backup carries exactly one file: the app's own +age-encrypted export (written only while a backup passphrase is set) — +include-rules keep the database and settings out, and restoring on a new +device requires typing the passphrase, since Keystore keys never migrate. ## Backup format diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/backup/CloudBackupRoundTripTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/backup/CloudBackupRoundTripTest.kt new file mode 100644 index 0000000..8515695 --- /dev/null +++ b/app/src/androidTest/java/no/naiv/meddetsamme/backup/CloudBackupRoundTripTest.kt @@ -0,0 +1,100 @@ +package no.naiv.meddetsamme.backup + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import no.naiv.meddetsamme.data.InventoryItem +import no.naiv.meddetsamme.data.MedDatabase +import no.naiv.meddetsamme.data.MedForm +import no.naiv.meddetsamme.data.Medication +import no.naiv.meddetsamme.settings.SettingsStore +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The Google Auto Backup path end to end, without the OS backup transport + * (which UI/automation can't drive deterministically): writeCloudBlob encrypts + * with the stored passphrase, pendingCloudRestore fires only on an empty DB, + * and import() with the right passphrase brings the data back. This is the + * "new phone" recovery, and it must keep working — Keystore won't migrate, so + * the typed passphrase is the only key. + */ +@RunWith(AndroidJUnit4::class) +class CloudBackupRoundTripTest { + + private val context = ApplicationProvider.getApplicationContext() + private val db get() = MedDatabase.get(context) + private val manager = BackupManager(context) + + @Before fun clear() { + runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) } + BackupManager.cloudBlobFile(context).delete() + } + + @After fun cleanup() { + runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) } + BackupManager.cloudBlobFile(context).delete() + SettingsStore(context).autoBackupPassphrase = null + } + + @Test fun writeThenRestoreOnEmptyDb() = runBlocking { + SettingsStore(context).autoBackupPassphrase = "korrekt hest" + val itemId = db.inventoryDao().insert( + InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0), + ) + db.medicationDao().insert(Medication(itemId = itemId, notes = "fastende")) + + manager.writeCloudBlob() + val blob = BackupManager.cloudBlobFile(context) + assertTrue("blob written", blob.exists()) + assertTrue("blob is an age v1 file", blob.readBytes().decodeToString(0, 21) == "age-encryption.org/v1") + + // Non-empty DB → no restore offered. + assertNull(manager.pendingCloudRestore()) + + // Simulate the new device: empty DB, blob restored by the OS. + db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) + val pending = manager.pendingCloudRestore() + assertNotNull("restore offered on empty DB", pending) + + val count = manager.import(pending!!.readBytes(), "korrekt hest".toCharArray()) + assertEquals(1, count) + assertEquals("Levaxin", db.inventoryDao().getAll().single().name) + assertEquals("fastende", db.medicationDao().getAllWithItems().single().med.notes) + } + + @Test fun wrongPassphraseFailsAndKeepsDbEmpty() = runBlocking { + SettingsStore(context).autoBackupPassphrase = "riktig" + val itemId = db.inventoryDao().insert( + InventoryItem(name = "Levaxin", strength = "50 µg", unit = "tablett", form = MedForm.TABLET, stockUnits = 42.0), + ) + db.medicationDao().insert(Medication(itemId = itemId)) + manager.writeCloudBlob() + db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) + + val blob = manager.pendingCloudRestore()!! + try { + manager.import(blob.readBytes(), "feil".toCharArray()) + org.junit.Assert.fail("wrong passphrase must throw") + } catch (_: Exception) { /* expected */ } + assertTrue("DB still empty after failed restore", db.inventoryDao().getAll().isEmpty()) + } + + @Test fun noPassphraseRemovesBlob() = runBlocking { + SettingsStore(context).autoBackupPassphrase = "x" + db.inventoryDao().insert(InventoryItem(name = "A", strength = "", unit = "stk", form = MedForm.OTHER)) + manager.writeCloudBlob() + assertTrue(BackupManager.cloudBlobFile(context).exists()) + + // Clearing the passphrase must stop offering data to Google. + SettingsStore(context).autoBackupPassphrase = null + manager.writeCloudBlob() + assertTrue("blob removed when passphrase cleared", !BackupManager.cloudBlobFile(context).exists()) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c00a0b2..0895d71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,16 +21,21 @@ - + + diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt b/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt index ffff681..eafe96e 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/AgeBackupCrypto.kt @@ -19,8 +19,14 @@ object AgeBackupCrypto : BackupCrypto { private val BINARY_MAGIC = "age-encryption.org/v1".toByteArray(Charsets.US_ASCII) private val ARMOR_MAGIC = "-----BEGIN AGE ENCRYPTED FILE-----".toByteArray(Charsets.US_ASCII) - /** age default work factor (2^18) — same cost `age -p` uses. */ - private const val WORK_FACTOR = 18 + /** + * 15, not age's desktop default 18: scrypt memory is 128·2^N·8 bytes, so + * 18 means 256 MiB — a guaranteed OutOfMemoryError inside a standard + * Android heap (found the hard way on-device). 15 = 32 MiB, still a real + * cost against bucket-compromise brute force. Decrypt reads the factor + * from the file header, so any of our files open anywhere. + */ + private const val WORK_FACTOR = 15 override val formatId = "age" 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 1b8f8db..7e16f1f 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupManager.kt @@ -1,6 +1,7 @@ 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 @@ -48,6 +49,48 @@ class BackupManager(private val context: Context) { 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. diff --git a/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt b/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt index c0fe6e1..998c0eb 100644 --- a/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt +++ b/app/src/main/java/no/naiv/meddetsamme/backup/BackupWorker.kt @@ -20,9 +20,17 @@ import no.naiv.meddetsamme.settings.SettingsStore class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + val manager = BackupManager(applicationContext) + // The Google Auto Backup blob is independent of S3: it only needs the + // passphrase, and failing to write it is a local error worth surfacing. + try { + manager.writeCloudBlob() + } catch (e: Exception) { + return if (runAttemptCount < 3) Result.retry() else Result.failure() + } if (!SettingsStore(applicationContext).isBackupConfigured) return Result.success() return try { - BackupManager(applicationContext).runAutoBackup() + manager.runAutoBackup() Result.success() } catch (e: Exception) { // Transient (network, clock skew): WorkManager retries with backoff. diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt index 1ccd1d5..1e16c71 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt @@ -164,12 +164,29 @@ fun BackupSettingsScreen(nav: Nav) { settings.s3AccessKey = accessKey.trim().ifBlank { null } settings.s3SecretKey = secretKey.trim().ifBlank { null } settings.autoBackupPassphrase = autoPassphrase.ifBlank { null } + // Write/remove the Google Auto Backup blob right away so the + // protection state matches the setting immediately. + scope.launch(Dispatchers.IO) { + try { + BackupManager(context).writeCloudBlob() + } catch (e: Exception) { + withContext(Dispatchers.Main) { toast("Kunne ikke skrive Google-kopi: ${e.message}") } + } + } toast("Lagret") }, enabled = !passphraseMismatch, modifier = Modifier.fillMaxWidth(), ) { Text("Lagre backupinnstillinger") } + Text( + "Google-sikkerhetskopi: når passordfrase er satt, legges en kryptert kopi i " + + "Googles enhetsbackup. Gjenoppretting på ny enhet krever at frasen tastes " + + "inn — den følger aldri med. Uten frase sendes ingenting til Google.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( onClick = { scope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt index cc28ae9..6901c6b 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/TodayScreen.kt @@ -167,6 +167,7 @@ fun TodayScreen(nav: Nav) { modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + item { CloudRestoreCard() } item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) } if (warnings.isNotEmpty()) { item { @@ -313,6 +314,64 @@ private fun DoseCard( } +/** + * "New phone" path: Google Auto Backup restored our encrypted blob onto a + * device with an empty database. The passphrase never migrates (Keystore is + * device-bound), so import demands it typed. + */ +@Composable +private fun CloudRestoreCard() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var pending by remember { mutableStateOf(null) } + var askPassphrase by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + pending = no.naiv.meddetsamme.backup.BackupManager(context).pendingCloudRestore() + } + val file = pending ?: return + + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Kryptert sikkerhetskopi funnet", style = MaterialTheme.typography.titleSmall) + Text( + "Google har gjenopprettet en kryptert kopi av medisindataene dine. " + + "Tast passordfrasen for å hente dem inn.", + style = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { askPassphrase = true }) { Text("Gjenopprett") } + TextButton(onClick = { + file.delete() + pending = null + }) { Text("Forkast kopien") } + } + } + } + + if (askPassphrase) { + PassphraseDialog( + title = "Gjenopprett fra Google-backup", + text = "Passordfrasen du satte for sikkerhetskopi på den forrige enheten.", + allowEmpty = false, + onDismiss = { askPassphrase = false }, + ) { passphrase -> + askPassphrase = false + scope.launch { + try { + val bytes = file.readBytes() + val count = no.naiv.meddetsamme.backup.BackupManager(context).import(bytes, passphrase) + no.naiv.meddetsamme.alarm.AlarmScheduler(context).armAll() + android.widget.Toast.makeText(context, "Gjenopprettet $count medisiner", android.widget.Toast.LENGTH_LONG).show() + pending = null + } catch (e: Exception) { + android.widget.Toast.makeText(context, "Gjenoppretting feilet: ${e.message}", android.widget.Toast.LENGTH_LONG).show() + } + } + } + } +} + /** * Quiet one-time card while backup is unconfigured: health data existing only * on this device is a real risk (proven the hard way on 2026-06-10). Goes diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index d4f7a70..36fb01b 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,12 @@ - + - - - - - + - - - - - + diff --git a/app/src/main/res/xml/full_backup_content.xml b/app/src/main/res/xml/full_backup_content.xml new file mode 100644 index 0000000..0e9db35 --- /dev/null +++ b/app/src/main/res/xml/full_backup_content.xml @@ -0,0 +1,5 @@ + + + + +