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:
parent
8c3cd0c965
commit
96ac8ea108
5 changed files with 404 additions and 267 deletions
291
app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt
Normal file
291
app/src/main/java/no/naiv/meddetsamme/ui/BackupSettingsScreen.kt
Normal 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}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue