med-det-samme/app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt
Ole-Morten Duesund 8e8156488a Test backup: run every configured mechanism, stop misreporting passphrase-only as unconfigured
The user's report: with only a backup passphrase set (no S3), 'Test
backup nå' errored 'ikke konfigurert' even though Google Auto Backup
works on the passphrase alone. New runConfiguredBackups() writes the
Google blob whenever a passphrase exists and uploads to S3 only when
those credentials are present, throwing only when NOTHING is set up.
The toast now names what actually ran ('Kryptert Google-kopi
oppdatert' and/or the bucket key). Two instrumented tests pin both
ends: passphrase-only succeeds, nothing-configured throws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:20:16 +02:00

315 lines
15 KiB
Kotlin

package no.naiv.meddetsamme.ui
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.backup.BackupManager
import no.naiv.meddetsamme.settings.SettingsStore
/**
* Everything backup: S3/Garage credentials, the stored auto-backup
* passphrase, manual test, restore-from-bucket, and file export/import.
* Its own screen — seven fields and three buttons drowned the rest of
* Settings when this lived inline.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupSettingsScreen(nav: Nav) {
val context = LocalContext.current
val settings = remember { SettingsStore(context) }
val scope = rememberCoroutineScope()
fun toast(msg: String) = Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
var endpoint by remember { mutableStateOf(settings.s3Endpoint ?: "") }
var region by remember { mutableStateOf(settings.s3Region ?: "garage") }
var bucket by remember { mutableStateOf(settings.s3Bucket ?: "") }
var accessKey by remember { mutableStateOf(settings.s3AccessKey ?: "") }
var secretKey by remember { mutableStateOf(settings.s3SecretKey ?: "") }
var autoPassphrase by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
var autoPassphraseConfirm by remember { mutableStateOf(settings.autoBackupPassphrase ?: "") }
// Export/import passphrase dialogs hold the pending document URI.
var exportUri 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(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> exportUri = uri }
val importPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> importUri = uri }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Sikkerhetskopi") },
navigationIcon = {
IconButton(onClick = { nav.pop() }) {
Icon(painterResource(R.drawable.ic_back), contentDescription = "Tilbake")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("S3/Garage", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = endpoint, onValueChange = { endpoint = it },
label = { Text("Endepunkt (https://…)") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = region, onValueChange = { region = it },
label = { Text("Region") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = bucket, onValueChange = { bucket = it },
label = { Text("Bøtte") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = accessKey, onValueChange = { accessKey = it },
label = { Text("Access key") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = secretKey, onValueChange = { secretKey = it },
label = { Text("Secret key") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = autoPassphrase, onValueChange = { autoPassphrase = it },
label = { Text("Passordfrase for autobackup") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = {
Text(
"Lagres på enheten slik at nattlig backup kan kjøre uten deg — " +
"beskytter bare mot at bøtta kommer på avveie. Tom = ukryptert backup.",
)
},
modifier = Modifier.fillMaxWidth(),
)
// Confirmation field: a typo here would encrypt every backup with a
// passphrase the user can't reproduce — unrecoverable by design.
val passphraseMismatch = autoPassphrase != autoPassphraseConfirm
OutlinedTextField(
value = autoPassphraseConfirm, onValueChange = { autoPassphraseConfirm = it },
label = { Text("Gjenta passordfrase") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = passphraseMismatch,
supportingText = { if (passphraseMismatch) Text("Passordfrasene er ikke like") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (passphraseMismatch) {
toast("Passordfrasene er ikke like")
return@Button
}
settings.s3Endpoint = endpoint.trim().ifBlank { null }
settings.s3Region = region.trim().ifBlank { null }
settings.s3Bucket = bucket.trim().ifBlank { null }
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) {
try {
val r = BackupManager(context).runConfiguredBackups()
val msg = buildString {
if (r.cloudBlobWritten) append("Kryptert Google-kopi oppdatert")
if (r.s3Key != null) {
if (isNotEmpty()) append("\n")
append("Lastet opp til bøtta: ${r.s3Key}")
}
}
withContext(Dispatchers.Main) { toast(msg) }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Backup feilet: ${e.message}") }
}
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Test backup nå") }
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()
Text("Eksport og import (fil)", style = MaterialTheme.typography.titleMedium)
OutlinedButton(
onClick = { exportPicker.launch("medisinbackup-${LocalDate.now()}.age") },
modifier = Modifier.fillMaxWidth(),
) { Text("Eksporter til fil …") }
OutlinedButton(
onClick = { importPicker.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth(),
) { Text("Importer fra fil …") }
}
}
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}") }
}
}
}
}
exportUri?.let { uri ->
PassphraseDialog(
title = "Krypter eksport",
text = "Skriv en passordfrase (lagres ikke). La stå tom for ukryptert JSON.",
allowEmpty = true,
requireConfirmation = true, // a typo here = an unrecoverable export
onDismiss = { exportUri = null },
) { passphrase ->
exportUri = null
scope.launch(Dispatchers.IO) {
try {
val manager = BackupManager(context)
val bytes = if (passphrase.isEmpty()) {
manager.exportJson().toByteArray()
} else {
manager.exportEncrypted(passphrase)
}
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) }
?: error("Fikk ikke åpnet filen")
withContext(Dispatchers.Main) { toast("Eksportert") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Eksport feilet: ${e.message}") }
}
}
}
}
importUri?.let { uri ->
PassphraseDialog(
title = "Importer sikkerhetskopi",
text = "ERSTATTER alle medisiner og all historikk på enheten. Passordfrase trengs bare for krypterte filer.",
allowEmpty = true,
onDismiss = { importUri = null },
) { passphrase ->
importUri = null
scope.launch(Dispatchers.IO) {
try {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Fikk ikke lest filen")
val count = BackupManager(context)
.import(bytes, passphrase.takeIf { it.isNotEmpty() })
AlarmScheduler(context).armAll() // restored schedules need alarms
withContext(Dispatchers.Main) { toast("Importerte $count medisiner") }
} catch (e: Exception) {
withContext(Dispatchers.Main) { toast("Import feilet: ${e.message}") }
}
}
}
}
}