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(null) } var importUri by remember { mutableStateOf(null) } // Downloaded-from-bucket backup awaiting confirmation: key + bytes. var bucketRestore by remember { mutableStateOf?>(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}") } } } } } }