mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
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:
@@ -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("*/*")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user