Google Auto Backup of the age-encrypted blob, passphrase-gated restore
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>
This commit is contained in:
parent
96ac8ea108
commit
c9ee76387f
11 changed files with 264 additions and 24 deletions
|
|
@ -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).
|
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
|
4. Surface a one-time battery-optimisation exemption prompt — biggest cause of dropped
|
||||||
reminders on aggressive OEMs.
|
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**,
|
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.
|
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
|
7. Crypto target is **age** (passphrase/scrypt) so backups are `age -d`-decryptable and
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,10 @@ ScheduleEngine — pure: next occurrence, daily consumption,
|
||||||
|
|
||||||
Room with exported schemas committed under `app/schemas/`; migrations are
|
Room with exported schemas committed under `app/schemas/`; migrations are
|
||||||
proven by instrumented `MigrationTestHelper` tests before they touch real
|
proven by instrumented `MigrationTestHelper` tests before they touch real
|
||||||
data. Google Auto Backup is disabled (`allowBackup=false` + full
|
data. Google Auto Backup carries exactly one file: the app's own
|
||||||
`dataExtractionRules` opt-out) — health data leaves the device only through
|
age-encrypted export (written only while a backup passphrase is set) —
|
||||||
the app's own encrypted backup.
|
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
|
## Backup format
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<android.content.Context>()
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,16 +21,21 @@
|
||||||
<!-- Backups to self-hosted S3 (Garage) and FEST dataset refresh only. -->
|
<!-- Backups to self-hosted S3 (Garage) and FEST dataset refresh only. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<!-- No Google Auto Backup: this app runs its own encrypted backup. allowBackup
|
<!-- largeHeap: scrypt for age files made elsewhere (CLI default 2^18)
|
||||||
covers pre-12; dataExtractionRules opts out of cloud backup AND device-to-
|
needs a 256 MiB working set on decrypt; our own files use 2^15. -->
|
||||||
device transfer on 12+. -->
|
<!-- Google Auto Backup carries EXACTLY ONE thing: the app's own
|
||||||
|
age-encrypted export in files/gbackup/ (see data_extraction_rules /
|
||||||
|
full_backup_content — includes beat excludes, so everything else is
|
||||||
|
out). The blob only exists when a passphrase is set, and restoring on
|
||||||
|
a new device requires typing it: Keystore keys never migrate. -->
|
||||||
<application
|
<application
|
||||||
android:name=".MedDetSammeApp"
|
android:name=".MedDetSammeApp"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:allowBackup="false"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="@xml/full_backup_content"
|
||||||
|
android:largeHeap="true"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.MedDetSamme">
|
android:theme="@style/Theme.MedDetSamme">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,14 @@ object AgeBackupCrypto : BackupCrypto {
|
||||||
private val BINARY_MAGIC = "age-encryption.org/v1".toByteArray(Charsets.US_ASCII)
|
private val BINARY_MAGIC = "age-encryption.org/v1".toByteArray(Charsets.US_ASCII)
|
||||||
private val ARMOR_MAGIC = "-----BEGIN AGE ENCRYPTED FILE-----".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"
|
override val formatId = "age"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package no.naiv.meddetsamme.backup
|
package no.naiv.meddetsamme.backup
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
@ -48,6 +49,48 @@ class BackupManager(private val context: Context) {
|
||||||
return file.medications.size
|
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
|
* Newest backup object in the bucket, or null when none exist. Keys embed
|
||||||
* a UTC timestamp, so the lexicographically last key is the newest.
|
* a UTC timestamp, so the lexicographically last key is the newest.
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,17 @@ import no.naiv.meddetsamme.settings.SettingsStore
|
||||||
class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
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()
|
if (!SettingsStore(applicationContext).isBackupConfigured) return Result.success()
|
||||||
return try {
|
return try {
|
||||||
BackupManager(applicationContext).runAutoBackup()
|
manager.runAutoBackup()
|
||||||
Result.success()
|
Result.success()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Transient (network, clock skew): WorkManager retries with backoff.
|
// Transient (network, clock skew): WorkManager retries with backoff.
|
||||||
|
|
|
||||||
|
|
@ -164,12 +164,29 @@ fun BackupSettingsScreen(nav: Nav) {
|
||||||
settings.s3AccessKey = accessKey.trim().ifBlank { null }
|
settings.s3AccessKey = accessKey.trim().ifBlank { null }
|
||||||
settings.s3SecretKey = secretKey.trim().ifBlank { null }
|
settings.s3SecretKey = secretKey.trim().ifBlank { null }
|
||||||
settings.autoBackupPassphrase = autoPassphrase.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")
|
toast("Lagret")
|
||||||
},
|
},
|
||||||
enabled = !passphraseMismatch,
|
enabled = !passphraseMismatch,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Lagre backupinnstillinger") }
|
) { 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(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ fun TodayScreen(nav: Nav) {
|
||||||
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
item { CloudRestoreCard() }
|
||||||
item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) }
|
item { BackupNudge(onSetup = { nav.push(Screen.BackupSettings) }) }
|
||||||
if (warnings.isNotEmpty()) {
|
if (warnings.isNotEmpty()) {
|
||||||
item {
|
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<java.io.File?>(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
|
* 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
|
* on this device is a real risk (proven the hard way on 2026-06-10). Goes
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Opt out of both Google cloud backup and device-to-device transfer (API 31+).
|
<!-- API 31+. With an <include> present, ONLY included paths are backed up:
|
||||||
Health data stays on-device; our own encrypted S3 backup is the only copy. -->
|
the age-encrypted export blob. DB, prefs and everything else stay on
|
||||||
|
the device — health data leaves only in encrypted form. -->
|
||||||
<data-extraction-rules>
|
<data-extraction-rules>
|
||||||
<cloud-backup>
|
<cloud-backup>
|
||||||
<exclude domain="root" path="." />
|
<include domain="file" path="gbackup/" />
|
||||||
<exclude domain="database" path="." />
|
|
||||||
<exclude domain="sharedpref" path="." />
|
|
||||||
<exclude domain="file" path="." />
|
|
||||||
<exclude domain="external" path="." />
|
|
||||||
</cloud-backup>
|
</cloud-backup>
|
||||||
<device-transfer>
|
<device-transfer>
|
||||||
<exclude domain="root" path="." />
|
<include domain="file" path="gbackup/" />
|
||||||
<exclude domain="database" path="." />
|
|
||||||
<exclude domain="sharedpref" path="." />
|
|
||||||
<exclude domain="file" path="." />
|
|
||||||
<exclude domain="external" path="." />
|
|
||||||
</device-transfer>
|
</device-transfer>
|
||||||
</data-extraction-rules>
|
</data-extraction-rules>
|
||||||
|
|
|
||||||
5
app/src/main/res/xml/full_backup_content.xml
Normal file
5
app/src/main/res/xml/full_backup_content.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- API 26–30 equivalent of data_extraction_rules: only the encrypted blob. -->
|
||||||
|
<full-backup-content>
|
||||||
|
<include domain="file" path="gbackup" />
|
||||||
|
</full-backup-content>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue