2026-06-11 19:00:50 +02:00
|
|
|
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 }
|
2026-06-11 19:20:48 +02:00
|
|
|
// 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}") }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 19:00:50 +02:00
|
|
|
toast("Lagret")
|
|
|
|
|
},
|
|
|
|
|
enabled = !passphraseMismatch,
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
) { Text("Lagre backupinnstillinger") }
|
|
|
|
|
|
2026-06-11 19:20:48 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-11 19:00:50 +02:00
|
|
|
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}") }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|