Backup gets its own settings page

Seven fields and three buttons drowned everything else in Settings.
S3/Garage config, test, restore-from-bucket and file export/import now
live on a dedicated Sikkerhetskopi page; main settings shows a status
card (red 'ikke satt opp' until configured) plus Varsler, FEST and
Legeoversikt. The Today-screen backup nudge deep-links straight to the
new page. PassphraseDialog moved to shared Components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-11 19:00:50 +02:00
commit 96ac8ea108
5 changed files with 404 additions and 267 deletions

View file

@ -0,0 +1,291 @@
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 }
toast("Lagret")
},
enabled = !passphraseMismatch,
modifier = Modifier.fillMaxWidth(),
) { Text("Lagre backupinnstillinger") }
OutlinedButton(
onClick = {
scope.launch(Dispatchers.IO) {
try {
val key = BackupManager(context).runAutoBackup()
withContext(Dispatchers.Main) { toast("Lastet opp: $key") }
} 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}") }
}
}
}
}
}