From a2c413478f50cf808fb0167c7f2fc5a0a2645b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 11:49:28 +0000 Subject: [PATCH] Cloud backup: per-account .ncryptsec files, Google Drive bottom sheet, move to backup screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each account backed up as its own {npub}.ncryptsec file (one ncryptsec per file) for both Google Drive and WebDAV - Google Drive: replaced folder-save flow with a ModalBottomSheet showing folder picker, Backup All and Restore from File buttons - WebDAV restore: npub text field instead of a fixed filename; downloads {npub}.ncryptsec from the server - CloudBackupScreen: Surface → Column; no more filename config field - AccountBackupScreen: added navController param; Export All Accounts and Cloud Backup entries now live at the bottom of the backup screen - SettingsScreen: removed Export All Accounts and Cloud Backup rows - strings.xml: added missing new strings https://claude.ai/code/session_01MCtBrEDEWfCfzppiGRaKEU --- .../service/AccountExportService.kt | 14 +- .../greenart7c3/nostrsigner/ui/MainScreen.kt | 1 + .../nostrsigner/ui/SettingsScreen.kt | 32 - .../ui/actions/AccountBackupScreen.kt | 25 + .../ui/actions/CloudBackupScreen.kt | 890 +++++++++--------- app/src/main/res/values/strings.xml | 13 +- 6 files changed, 513 insertions(+), 462 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt index e67354fd..70a6db20 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt @@ -82,26 +82,26 @@ object AccountExportService { } /** - * Export all accounts as ncryptsec lines (NIP-49 encrypted private keys). - * One ncryptsec per line — no JSON metadata, compatible with any Nostr client. + * Export each account as a separate (npub, ncryptsec) pair. + * Callers write each pair to its own file named "{npub}.ncryptsec". */ - suspend fun exportAllAsNcryptsec( + suspend fun exportPerAccountNcryptsec( context: Context, password: String, onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }, - ): Result = withContext(Dispatchers.IO) { + ): Result>> = withContext(Dispatchers.IO) { try { val accountInfos = LocalPreferences.allSavedAccounts(context) val total = accountInfos.size if (total == 0) return@withContext Result.failure(Exception("No accounts to export")) - val lines = StringBuilder() + val result = mutableListOf>() accountInfos.forEachIndexed { index, info -> onProgress(index + 1, total) val account = LocalPreferences.loadFromEncryptedStorage(context, info.npub) - account?.let { lines.appendLine(it.nip49Encrypt(password)) } + account?.let { result.add(info.npub to it.nip49Encrypt(password)) } } - Result.success(lines.toString().trimEnd()) + Result.success(result) } catch (e: Exception) { Result.failure(e) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt index 85c88c58..fe532e7b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -462,6 +462,7 @@ fun MainScreen( .padding(horizontal = verticalPadding) .padding(top = verticalPadding * 1.5f) .imePadding(), + navController = navController.navController, onShowQrCode = { Amber.instance.applicationIOScope.launch(Dispatchers.Main) { navController.navController.navigate(Route.QrCode.route.replace("{content}", it)) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt index 3259fad8..2c42528c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt @@ -11,13 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.Feedback import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material3.AlertDialog @@ -148,36 +146,6 @@ fun SettingsScreen( ) } - Box( - Modifier - .padding(vertical = 8.dp), - ) { - IconRow( - title = stringResource(R.string.export_all_accounts_title), - icon = Icons.Default.SaveAlt, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - navController.navigate(Route.ExportAllAccounts.route) - }, - ) - } - - if (!BuildFlavorChecker.isOfflineFlavor()) { - Box( - Modifier - .padding(vertical = 8.dp), - ) { - IconRow( - title = stringResource(R.string.cloud_backup_title), - icon = Icons.Default.CloudUpload, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - navController.navigate(Route.CloudBackup.route) - }, - ) - } - } - Box( Modifier .padding(vertical = 8.dp), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt index ba266435..2764cdb9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController import coil3.compose.SubcomposeAsyncImage import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildFlavorChecker @@ -100,7 +101,9 @@ import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator import com.greenart7c3.nostrsigner.ui.InnerQrCodeDrawer import com.greenart7c3.nostrsigner.ui.QrCodeDrawer import com.greenart7c3.nostrsigner.ui.components.CloseButton +import com.greenart7c3.nostrsigner.ui.components.IconRow import com.greenart7c3.nostrsigner.ui.components.SeedWordsPage +import com.greenart7c3.nostrsigner.ui.navigation.Route import com.greenart7c3.nostrsigner.ui.theme.Size35dp import com.greenart7c3.nostrsigner.ui.theme.fromHex import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions @@ -117,6 +120,7 @@ import kotlinx.coroutines.launch @Composable fun AccountBackupScreen( modifier: Modifier, + navController: NavController, onShowQrCode: (String) -> Unit, ) { var isLoading by remember { mutableStateOf(false) } @@ -186,6 +190,27 @@ fun AccountBackupScreen( Spacer(modifier = Modifier.height(12.dp)) } } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + IconRow( + title = stringResource(R.string.export_all_accounts_title), + icon = androidx.compose.material.icons.Icons.Default.SaveAlt, + tint = MaterialTheme.colorScheme.onBackground, + onClick = { navController.navigate(Route.ExportAllAccounts.route) }, + ) + + if (!BuildFlavorChecker.isOfflineFlavor()) { + Spacer(modifier = Modifier.height(4.dp)) + IconRow( + title = stringResource(R.string.cloud_backup_title), + icon = androidx.compose.material.icons.Icons.Default.CloudUpload, + tint = MaterialTheme.colorScheme.onBackground, + onClick = { navController.navigate(Route.CloudBackup.route) }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/CloudBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/CloudBackupScreen.kt index a61cd719..e371e29f 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/CloudBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/CloudBackupScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -19,13 +18,16 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff +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.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,16 +59,11 @@ import com.greenart7c3.nostrsigner.service.Biometrics.authenticate import com.greenart7c3.nostrsigner.service.WebDavService import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator import com.greenart7c3.nostrsigner.ui.components.AmberButton -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -private const val DEFAULT_WEBDAV_FILENAME = "amber_backup.txt" - -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable fun CloudBackupScreen(modifier: Modifier) { val context = LocalContext.current @@ -85,10 +82,11 @@ fun CloudBackupScreen(modifier: Modifier) { var webDavUsername by remember { mutableStateOf(TextFieldValue("")) } var webDavPassword by remember { mutableStateOf(TextFieldValue("")) } var showWebDavPassword by remember { mutableStateOf(false) } - var webDavFilename by remember { mutableStateOf(TextFieldValue(DEFAULT_WEBDAV_FILENAME)) } var webDavError by remember { mutableStateOf("") } + var webDavRestoreNpub by remember { mutableStateOf(TextFieldValue("")) } // Google Drive state + var showGdriveSheet by remember { mutableStateOf(false) } var gdriveFolderUri by remember { mutableStateOf(null) } var gdriveFolderName by remember { mutableStateOf("") } @@ -97,7 +95,6 @@ fun CloudBackupScreen(modifier: Modifier) { webDavUrl = TextFieldValue(LocalPreferences.getWebDavUrl(context)) webDavUsername = TextFieldValue(LocalPreferences.getWebDavUsername(context)) webDavPassword = TextFieldValue(LocalPreferences.getWebDavPassword(context)) - webDavFilename = TextFieldValue(LocalPreferences.getWebDavFilename(context)) val savedUri = LocalPreferences.getGdriveFolderUri(context) if (savedUri.isNotBlank()) { @@ -111,7 +108,6 @@ fun CloudBackupScreen(modifier: Modifier) { val keyguardLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - // Google Drive: pick folder val folderPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> uri?.let { try { @@ -126,7 +122,7 @@ fun CloudBackupScreen(modifier: Modifier) { } } - // Google Drive: pick a backup file for restore (reads ncryptsec lines) + // Pick a single .ncryptsec file from Google Drive for restore val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { fileUri -> if (password.text.isBlank()) { @@ -156,428 +152,193 @@ fun CloudBackupScreen(modifier: Modifier) { } } - Surface(modifier = modifier.fillMaxSize()) { - if (isLoading) { - CenterCircularProgressIndicator(Modifier, statusText) - return@Surface - } + if (isLoading) { + CenterCircularProgressIndicator(modifier, statusText) + return + } - Column( + Column(modifier = modifier.padding(16.dp)) { + // --- Encryption password (shared) --- + Text( + text = stringResource(R.string.cloud_backup_encryption_section), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.cloud_backup_encryption_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // --- Encryption password (shared) --- - Text( - text = stringResource(R.string.cloud_backup_encryption_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.cloud_backup_encryption_description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth().semantics { contentType = ContentType.Password }, - value = password, - onValueChange = { - password = it - passwordError = "" - }, - label = { Text(text = stringResource(R.string.encryption_password)) }, - placeholder = { Text(text = stringResource(R.string.enter_strong_password)) }, - isError = passwordError.isNotBlank(), - supportingText = if (passwordError.isNotBlank()) { - { Text(text = passwordError, color = MaterialTheme.colorScheme.error) } - } else { - null - }, - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) { - stringResource(R.string.hide_password) - } else { - stringResource(R.string.show_password) - }, - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), - singleLine = true, - ) - - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(8.dp)) - - // --- Google Drive section --- - Text( - text = stringResource(R.string.cloud_backup_gdrive_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.cloud_backup_gdrive_description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - ) - - if (gdriveFolderName.isNotBlank()) { - Text( - text = stringResource(R.string.cloud_backup_folder_label, gdriveFolderName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } - - AmberButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource( - if (gdriveFolderUri == null) { - R.string.cloud_backup_gdrive_pick_folder - } else { - R.string.cloud_backup_gdrive_change_folder - }, - ), - onClick = { folderPickerLauncher.launch(null) }, - ) - - if (gdriveFolderUri != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AmberButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cloud_backup_gdrive_backup), - onClick = { - when { - password.text.isBlank() -> passwordError = context.getString(R.string.cloud_backup_password_required) - password.text.length < 8 -> passwordError = context.getString(R.string.password_too_short) - else -> { - authenticate( - title = context.getString(R.string.cloud_backup_gdrive_backup), - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { - Amber.instance.applicationIOScope.launch { - isLoading = true - statusText = context.getString(R.string.preparing_export) - val result = AccountExportService.exportAllAsNcryptsec( - context = context, - password = password.text, - onProgress = { cur, tot -> - statusText = context.getString(R.string.exporting_accounts_progress, cur, tot) - }, - ) - result.onSuccess { data -> - try { - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileName = "amber_backup_$timestamp.txt" - val folderUri = gdriveFolderUri!! - val docUri = DocumentsContract.createDocument( - context.contentResolver, - DocumentsContract.buildDocumentUriUsingTree( - folderUri, - DocumentsContract.getTreeDocumentId(folderUri), - ), - "text/plain", - fileName, - ) - docUri?.let { fileUri -> - context.contentResolver.openOutputStream(fileUri)?.use { stream -> - stream.write(data.toByteArray(Charsets.UTF_8)) - } - } - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_success, fileName), Toast.LENGTH_LONG).show() - isLoading = false - statusText = "" - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() - isLoading = false - statusText = "" - } - } - }.onFailure { e -> - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() - isLoading = false - statusText = "" - } - } - } - }, - onError = { _, msg -> - Amber.instance.applicationIOScope.launch(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - }, - ) - } - } - }, - ) - - AmberButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cloud_backup_gdrive_restore), - onClick = { - if (password.text.isBlank()) { - passwordError = context.getString(R.string.cloud_backup_password_required) - } else { - filePickerLauncher.launch("*/*") - } + .fillMaxWidth() + .semantics { contentType = ContentType.Password }, + value = password, + onValueChange = { + password = it + passwordError = "" + }, + label = { Text(text = stringResource(R.string.encryption_password)) }, + placeholder = { Text(text = stringResource(R.string.enter_strong_password)) }, + isError = passwordError.isNotBlank(), + supportingText = if (passwordError.isNotBlank()) { + { Text(text = passwordError, color = MaterialTheme.colorScheme.error) } + } else { + null + }, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showPassword) { + stringResource(R.string.hide_password) + } else { + stringResource(R.string.show_password) }, ) } - } + }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + singleLine = true, + ) - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) - // --- WebDAV section --- - Text( - text = stringResource(R.string.cloud_backup_webdav_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.cloud_backup_webdav_description), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - ) + // --- Google Drive row --- + Text( + text = stringResource(R.string.cloud_backup_gdrive_section), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.cloud_backup_gdrive_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + Spacer(modifier = Modifier.height(8.dp)) + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_gdrive_open), + onClick = { + if (password.text.isBlank()) { + passwordError = context.getString(R.string.cloud_backup_password_required) + } else { + showGdriveSheet = true + } + }, + ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = webDavUrl, - onValueChange = { - webDavUrl = it - webDavError = "" - }, - label = { Text(text = stringResource(R.string.cloud_backup_webdav_url)) }, - placeholder = { Text(text = "https://dav.example.com/remote.php/dav/files/user/") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), - singleLine = true, - ) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = webDavUsername, - onValueChange = { - webDavUsername = it - webDavError = "" - }, - label = { Text(text = stringResource(R.string.cloud_backup_webdav_username)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), - singleLine = true, - ) + // --- WebDAV section --- + Text( + text = stringResource(R.string.cloud_backup_webdav_section), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.cloud_backup_webdav_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth().semantics { contentType = ContentType.Password }, - value = webDavPassword, - onValueChange = { - webDavPassword = it - webDavError = "" - }, - label = { Text(text = stringResource(R.string.cloud_backup_webdav_password)) }, - trailingIcon = { - IconButton(onClick = { showWebDavPassword = !showWebDavPassword }) { - Icon( - imageVector = if (showWebDavPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showWebDavPassword) { - stringResource(R.string.hide_password) - } else { - stringResource(R.string.show_password) - }, + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = webDavUrl, + onValueChange = { + webDavUrl = it + webDavError = "" + }, + label = { Text(text = stringResource(R.string.cloud_backup_webdav_url)) }, + placeholder = { Text(text = "https://dav.example.com/remote.php/dav/files/user/") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = webDavUsername, + onValueChange = { + webDavUsername = it + webDavError = "" + }, + label = { Text(text = stringResource(R.string.cloud_backup_webdav_username)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.Password }, + value = webDavPassword, + onValueChange = { + webDavPassword = it + webDavError = "" + }, + label = { Text(text = stringResource(R.string.cloud_backup_webdav_password)) }, + trailingIcon = { + IconButton(onClick = { showWebDavPassword = !showWebDavPassword }) { + Icon( + imageVector = if (showWebDavPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showWebDavPassword) { + stringResource(R.string.hide_password) + } else { + stringResource(R.string.show_password) + }, + ) + } + }, + visualTransformation = if (showWebDavPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + singleLine = true, + ) + + if (webDavError.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = webDavError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AmberButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.cloud_backup_webdav_save), + onClick = { + Amber.instance.applicationIOScope.launch { + LocalPreferences.saveWebDavSettings( + context = context, + url = webDavUrl.text, + username = webDavUsername.text, + password = webDavPassword.text, + fileName = "", ) + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_webdav_saved), Toast.LENGTH_SHORT).show() + } } }, - visualTransformation = if (showWebDavPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next), - singleLine = true, ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = webDavFilename, - onValueChange = { - webDavFilename = it - webDavError = "" - }, - label = { Text(text = stringResource(R.string.cloud_backup_webdav_filename)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), - singleLine = true, - ) - - if (webDavError.isNotBlank()) { - Text(text = webDavError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - AmberButton( - modifier = Modifier, - text = stringResource(R.string.cloud_backup_webdav_save), - onClick = { - Amber.instance.applicationIOScope.launch { - LocalPreferences.saveWebDavSettings( - context = context, - url = webDavUrl.text, - username = webDavUsername.text, - password = webDavPassword.text, - fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME }, - ) - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_webdav_saved), Toast.LENGTH_SHORT).show() - } - } - }, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AmberButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cloud_backup_webdav_backup), - onClick = { - when { - password.text.isBlank() -> passwordError = context.getString(R.string.cloud_backup_password_required) - password.text.length < 8 -> passwordError = context.getString(R.string.password_too_short) - webDavUrl.text.isBlank() -> webDavError = context.getString(R.string.cloud_backup_webdav_url_required) - else -> { - authenticate( - title = context.getString(R.string.cloud_backup_webdav_backup), - context = context, - keyguardLauncher = keyguardLauncher, - onApproved = { - Amber.instance.applicationIOScope.launch { - isLoading = true - statusText = context.getString(R.string.preparing_export) - - LocalPreferences.saveWebDavSettings( - context = context, - url = webDavUrl.text, - username = webDavUsername.text, - password = webDavPassword.text, - fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME }, - ) - - val exportResult = AccountExportService.exportAllAsNcryptsec( - context = context, - password = password.text, - onProgress = { cur, tot -> - statusText = context.getString(R.string.exporting_accounts_progress, cur, tot) - }, - ) - - exportResult.onSuccess { data -> - statusText = context.getString(R.string.cloud_backup_uploading) - val uploadResult = withContext(Dispatchers.IO) { - WebDavService.uploadFile( - serverUrl = webDavUrl.text, - username = webDavUsername.text, - password = webDavPassword.text, - fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME }, - content = data, - ) - } - withContext(Dispatchers.Main) { - if (uploadResult.isSuccess) { - Toast.makeText(context, context.getString(R.string.cloud_backup_success, webDavFilename.text), Toast.LENGTH_LONG).show() - } else { - Toast.makeText(context, context.getString(R.string.cloud_backup_failed, uploadResult.exceptionOrNull()?.message), Toast.LENGTH_LONG).show() - } - isLoading = false - statusText = "" - } - }.onFailure { e -> - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() - isLoading = false - statusText = "" - } - } - } - }, - onError = { _, msg -> - Amber.instance.applicationIOScope.launch(Dispatchers.Main) { - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - }, - ) - } - } - }, - ) - - AmberButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cloud_backup_webdav_restore), - onClick = { - when { - password.text.isBlank() -> passwordError = context.getString(R.string.cloud_backup_password_required) - webDavUrl.text.isBlank() -> webDavError = context.getString(R.string.cloud_backup_webdav_url_required) - else -> { - Amber.instance.applicationIOScope.launch { - isLoading = true - statusText = context.getString(R.string.cloud_backup_downloading) - - val downloadResult = withContext(Dispatchers.IO) { - WebDavService.downloadLatestFile( - serverUrl = webDavUrl.text, - username = webDavUsername.text, - password = webDavPassword.text, - fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME }, - ) - } - - downloadResult.onSuccess { data -> - AccountExportService.importFromNcryptsec( - content = data, - password = password.text, - onLoading = { loading -> isLoading = loading }, - onText = { t -> statusText = t }, - onFinish = { - Amber.instance.applicationIOScope.launch(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_restore_success), Toast.LENGTH_LONG).show() - statusText = "" - isLoading = false - } - }, - ) - }.onFailure { e -> - withContext(Dispatchers.Main) { - Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() - isLoading = false - statusText = "" - } - } - } - } - } - }, - ) - } - AmberButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), text = stringResource(R.string.cloud_backup_webdav_test), onClick = { if (webDavUrl.text.isBlank()) { @@ -606,8 +367,299 @@ fun CloudBackupScreen(modifier: Modifier) { } }, ) + } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(8.dp)) + + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_webdav_backup), + onClick = { + when { + password.text.isBlank() -> passwordError = context.getString(R.string.cloud_backup_password_required) + password.text.length < 8 -> passwordError = context.getString(R.string.password_too_short) + webDavUrl.text.isBlank() -> webDavError = context.getString(R.string.cloud_backup_webdav_url_required) + else -> { + authenticate( + title = context.getString(R.string.cloud_backup_webdav_backup), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { + Amber.instance.applicationIOScope.launch { + isLoading = true + statusText = context.getString(R.string.preparing_export) + + LocalPreferences.saveWebDavSettings( + context = context, + url = webDavUrl.text, + username = webDavUsername.text, + password = webDavPassword.text, + fileName = "", + ) + + val exportResult = AccountExportService.exportPerAccountNcryptsec( + context = context, + password = password.text, + onProgress = { cur, tot -> + statusText = context.getString(R.string.exporting_accounts_progress, cur, tot) + }, + ) + + exportResult.onSuccess { pairs -> + var allOk = true + pairs.forEachIndexed { index, (npub, ncryptsec) -> + statusText = context.getString(R.string.cloud_backup_uploading) + " (${ index + 1}/${pairs.size})" + val uploadResult = withContext(Dispatchers.IO) { + WebDavService.uploadFile( + serverUrl = webDavUrl.text, + username = webDavUsername.text, + password = webDavPassword.text, + fileName = "$npub.ncryptsec", + content = ncryptsec, + ) + } + if (uploadResult.isFailure) allOk = false + } + withContext(Dispatchers.Main) { + if (allOk) { + Toast.makeText(context, context.getString(R.string.cloud_backup_success, "${pairs.size} accounts"), Toast.LENGTH_LONG).show() + } else { + Toast.makeText(context, context.getString(R.string.cloud_backup_partial_failure), Toast.LENGTH_LONG).show() + } + isLoading = false + statusText = "" + } + }.onFailure { e -> + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() + isLoading = false + statusText = "" + } + } + } + }, + onError = { _, msg -> + Amber.instance.applicationIOScope.launch(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }, + ) + } + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Restore: user specifies the npub to download + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = webDavRestoreNpub, + onValueChange = { webDavRestoreNpub = it }, + label = { Text(text = stringResource(R.string.cloud_backup_webdav_restore_npub)) }, + placeholder = { Text(text = "npub1...") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_webdav_restore), + onClick = { + when { + password.text.isBlank() -> passwordError = context.getString(R.string.cloud_backup_password_required) + webDavUrl.text.isBlank() -> webDavError = context.getString(R.string.cloud_backup_webdav_url_required) + webDavRestoreNpub.text.isBlank() -> webDavError = context.getString(R.string.cloud_backup_webdav_npub_required) + else -> { + Amber.instance.applicationIOScope.launch { + isLoading = true + statusText = context.getString(R.string.cloud_backup_downloading) + + val downloadResult = withContext(Dispatchers.IO) { + WebDavService.downloadLatestFile( + serverUrl = webDavUrl.text, + username = webDavUsername.text, + password = webDavPassword.text, + fileName = "${webDavRestoreNpub.text.trim()}.ncryptsec", + ) + } + + downloadResult.onSuccess { data -> + AccountExportService.importFromNcryptsec( + content = data, + password = password.text, + onLoading = { loading -> isLoading = loading }, + onText = { t -> statusText = t }, + onFinish = { + Amber.instance.applicationIOScope.launch(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_restore_success), Toast.LENGTH_LONG).show() + statusText = "" + isLoading = false + } + }, + ) + }.onFailure { e -> + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() + isLoading = false + statusText = "" + } + } + } + } + } + }, + ) + } + + // Google Drive bottom sheet + if (showGdriveSheet) { + ModalBottomSheet( + onDismissRequest = { showGdriveSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.cloud_backup_gdrive_section), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.cloud_backup_gdrive_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + + HorizontalDivider() + + // Folder row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = if (gdriveFolderName.isNotBlank()) { + stringResource(R.string.cloud_backup_folder_label, gdriveFolderName) + } else { + stringResource(R.string.cloud_backup_no_folder) + }, + style = MaterialTheme.typography.bodyMedium, + color = if (gdriveFolderName.isNotBlank()) MaterialTheme.colorScheme.primary else Color.Gray, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { folderPickerLauncher.launch(null) }) { + Text( + text = stringResource( + if (gdriveFolderUri == null) { + R.string.cloud_backup_gdrive_pick_folder + } else { + R.string.cloud_backup_gdrive_change_folder + }, + ), + ) + } + } + + HorizontalDivider() + + if (gdriveFolderUri != null) { + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_gdrive_backup), + onClick = { + authenticate( + title = context.getString(R.string.cloud_backup_gdrive_backup), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { + showGdriveSheet = false + Amber.instance.applicationIOScope.launch { + isLoading = true + statusText = context.getString(R.string.preparing_export) + val result = AccountExportService.exportPerAccountNcryptsec( + context = context, + password = password.text, + onProgress = { cur, tot -> + statusText = context.getString(R.string.exporting_accounts_progress, cur, tot) + }, + ) + result.onSuccess { pairs -> + try { + var allOk = true + val folderUri = gdriveFolderUri!! + pairs.forEachIndexed { index, (npub, ncryptsec) -> + statusText = context.getString(R.string.cloud_backup_uploading) + " (${index + 1}/${pairs.size})" + val docUri = DocumentsContract.createDocument( + context.contentResolver, + DocumentsContract.buildDocumentUriUsingTree( + folderUri, + DocumentsContract.getTreeDocumentId(folderUri), + ), + "text/plain", + "$npub.ncryptsec", + ) + if (docUri != null) { + context.contentResolver.openOutputStream(docUri)?.use { stream -> + stream.write(ncryptsec.toByteArray(Charsets.UTF_8)) + } + } else { + allOk = false + } + } + withContext(Dispatchers.Main) { + if (allOk) { + Toast.makeText(context, context.getString(R.string.cloud_backup_success, "${pairs.size} accounts"), Toast.LENGTH_LONG).show() + } else { + Toast.makeText(context, context.getString(R.string.cloud_backup_partial_failure), Toast.LENGTH_LONG).show() + } + isLoading = false + statusText = "" + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() + isLoading = false + statusText = "" + } + } + }.onFailure { e -> + withContext(Dispatchers.Main) { + Toast.makeText(context, context.getString(R.string.cloud_backup_failed, e.message), Toast.LENGTH_LONG).show() + isLoading = false + statusText = "" + } + } + } + }, + onError = { _, msg -> + Amber.instance.applicationIOScope.launch(Dispatchers.Main) { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }, + ) + }, + ) + } + + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_gdrive_restore), + onClick = { + showGdriveSheet = false + filePickerLauncher.launch("*/*") + }, + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a79b9fd1..226813ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -453,12 +453,17 @@ Password is required to backup or restore Google Drive Pick a Google Drive folder using the system file picker. No API key required — Android handles the access. - Pick Google Drive Folder - Change Folder - Backup - Restore (pick file) + Pick Folder + Change + Open Google Drive + Backup All Accounts + Restore from File Folder selected + No folder selected Folder: %s + Some accounts could not be saved. Check your connection. + npub to restore + Enter the npub of the account to restore WebDAV Upload your encrypted backup to any WebDAV server (Nextcloud, ownCloud, etc.). Server URL