Cloud backup: per-account .ncryptsec files, Google Drive bottom sheet, move to backup screen

- 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
This commit is contained in:
Claude
2026-04-08 13:41:13 -03:00
committed by greenart7c3
parent 4eec5ca397
commit a2c413478f
6 changed files with 513 additions and 462 deletions
@@ -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<String> = withContext(Dispatchers.IO) {
): Result<List<Pair<String, String>>> = 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<Pair<String, String>>()
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)
}
@@ -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))
@@ -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),
@@ -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))
}
}
}
@@ -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<Uri?>(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("*/*")
},
)
}
}
}
}
+9 -4
View File
@@ -453,12 +453,17 @@
<string name="cloud_backup_password_required">Password is required to backup or restore</string>
<string name="cloud_backup_gdrive_section">Google Drive</string>
<string name="cloud_backup_gdrive_description">Pick a Google Drive folder using the system file picker. No API key required — Android handles the access.</string>
<string name="cloud_backup_gdrive_pick_folder">Pick Google Drive Folder</string>
<string name="cloud_backup_gdrive_change_folder">Change Folder</string>
<string name="cloud_backup_gdrive_backup">Backup</string>
<string name="cloud_backup_gdrive_restore">Restore (pick file)</string>
<string name="cloud_backup_gdrive_pick_folder">Pick Folder</string>
<string name="cloud_backup_gdrive_change_folder">Change</string>
<string name="cloud_backup_gdrive_open">Open Google Drive</string>
<string name="cloud_backup_gdrive_backup">Backup All Accounts</string>
<string name="cloud_backup_gdrive_restore">Restore from File</string>
<string name="cloud_backup_folder_selected">Folder selected</string>
<string name="cloud_backup_no_folder">No folder selected</string>
<string name="cloud_backup_folder_label">Folder: %s</string>
<string name="cloud_backup_partial_failure">Some accounts could not be saved. Check your connection.</string>
<string name="cloud_backup_webdav_restore_npub">npub to restore</string>
<string name="cloud_backup_webdav_npub_required">Enter the npub of the account to restore</string>
<string name="cloud_backup_webdav_section">WebDAV</string>
<string name="cloud_backup_webdav_description">Upload your encrypted backup to any WebDAV server (Nextcloud, ownCloud, etc.).</string>
<string name="cloud_backup_webdav_url">Server URL</string>