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? { 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 } }