diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index e8dd09f0..ddec4b1c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -60,6 +60,11 @@ private enum class SettingsKeys(val key: String) { AUTO_CHECK_UPDATES("auto_check_updates"), UPDATE_CHECK_FREQUENCY("update_check_frequency"), LAST_UPDATE_CHECK_TIME("last_update_check_time"), + WEBDAV_URL("webdav_url"), + WEBDAV_USERNAME("webdav_username"), + WEBDAV_PASSWORD_ENCRYPTED("webdav_password_encrypted"), + WEBDAV_FILENAME("webdav_filename"), + GDRIVE_FOLDER_URI("gdrive_folder_uri"), } @Immutable @@ -517,6 +522,53 @@ object LocalPreferences { } } + // WebDAV settings + fun getWebDavUrl(context: Context): String = sharedPrefs(context).getString(SettingsKeys.WEBDAV_URL.key, "") ?: "" + + fun getWebDavUsername(context: Context): String = sharedPrefs(context).getString(SettingsKeys.WEBDAV_USERNAME.key, "") ?: "" + + fun getWebDavFilename(context: Context): String = sharedPrefs(context).getString(SettingsKeys.WEBDAV_FILENAME.key, "amber_backup.txt") ?: "amber_backup.txt" + + suspend fun getWebDavPassword(context: Context): String { + val encrypted = sharedPrefs(context).getString(SettingsKeys.WEBDAV_PASSWORD_ENCRYPTED.key, "") ?: "" + return if (encrypted.isBlank()) { + "" + } else { + try { + SecureCryptoHelper.decrypt(encrypted) + } catch (e: Exception) { + "" + } + } + } + + suspend fun saveWebDavSettings( + context: Context, + url: String, + username: String, + password: String, + fileName: String, + ) { + val encryptedPassword = if (password.isBlank()) "" else SecureCryptoHelper.encrypt(password) + sharedPrefs(context).edit { + apply { + putString(SettingsKeys.WEBDAV_URL.key, url) + putString(SettingsKeys.WEBDAV_USERNAME.key, username) + putString(SettingsKeys.WEBDAV_PASSWORD_ENCRYPTED.key, encryptedPassword) + putString(SettingsKeys.WEBDAV_FILENAME.key, fileName) + } + } + } + + // Google Drive folder URI (persisted SAF permission) + fun getGdriveFolderUri(context: Context): String = sharedPrefs(context).getString(SettingsKeys.GDRIVE_FOLDER_URI.key, "") ?: "" + + fun saveGdriveFolderUri(context: Context, uri: String) { + sharedPrefs(context).edit { + apply { putString(SettingsKeys.GDRIVE_FOLDER_URI.key, uri) } + } + } + suspend fun loadFromEncryptedStorage(context: Context, npub: String): Account? { if (accountCache.get(npub) != null) { return accountCache.get(npub) 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 2218e181..70a6db20 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt @@ -81,6 +81,72 @@ object AccountExportService { } } + /** + * Export each account as a separate (npub, ncryptsec) pair. + * Callers write each pair to its own file named "{npub}.ncryptsec". + */ + suspend fun exportPerAccountNcryptsec( + context: Context, + password: String, + onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }, + ): 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 result = mutableListOf>() + accountInfos.forEachIndexed { index, info -> + onProgress(index + 1, total) + val account = LocalPreferences.loadFromEncryptedStorage(context, info.npub) + account?.let { result.add(info.npub to it.nip49Encrypt(password)) } + } + Result.success(result) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Import accounts from ncryptsec lines (one ncryptsec per line). + */ + suspend fun importFromNcryptsec( + content: String, + password: String, + onLoading: (isLoading: Boolean) -> Unit, + onText: (text: String) -> Unit, + onFinish: () -> Unit, + ) { + onLoading(true) + try { + content.lines().filter { it.isNotBlank() }.forEachIndexed { index, ncryptsec -> + try { + onText(Amber.instance.getString(R.string.importing_account, index + 1)) + val privKey = Nip49().decrypt(ncryptsec, password) + val hexKey = Nip01Crypto.pubKeyCreate(Hex.decode(privKey)) + val account = Account( + hexKey = hexKey.toHexKey(), + npub = hexKey.toNpub(), + name = MutableStateFlow(""), + picture = MutableStateFlow(""), + signPolicy = 1, + didBackup = true, + signer = NostrSignerInternal(KeyPair(privKey.hexToByteArray())), + ) + LocalPreferences.switchToAccount(Amber.instance, hexKey.toNpub()) + LocalPreferences.updatePrefsForLogin(Amber.instance, account, hexKey.toHexKey(), privKey, null) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Amber", "Error importing ncryptsec at index $index", e) + } + } + onFinish() + } finally { + onText("") + onLoading(false) + } + } + /** * Export all accounts as an encrypted JSON file * diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/WebDavService.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/WebDavService.kt new file mode 100644 index 00000000..8c34e358 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/WebDavService.kt @@ -0,0 +1,104 @@ +package com.greenart7c3.nostrsigner.service + +import java.io.IOException +import java.util.concurrent.TimeUnit +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +object WebDavService { + private val MEDIA_TYPE = "application/octet-stream".toMediaType() + + private fun createClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + fun uploadFile( + serverUrl: String, + username: String, + password: String, + fileName: String, + content: String, + ): Result = try { + val client = createClient() + val url = serverUrl.trimEnd('/') + "/" + fileName + val credential = Credentials.basic(username, password) + + val request = Request.Builder() + .url(url) + .header("Authorization", credential) + .put(content.toByteArray(Charsets.UTF_8).toRequestBody(MEDIA_TYPE)) + .build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful || response.code == 201 || response.code == 204) { + Result.success(Unit) + } else { + Result.failure(IOException("Server returned ${response.code}: ${response.message}")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + + fun downloadLatestFile( + serverUrl: String, + username: String, + password: String, + fileName: String, + ): Result = try { + val client = createClient() + val url = serverUrl.trimEnd('/') + "/" + fileName + val credential = Credentials.basic(username, password) + + val request = Request.Builder() + .url(url) + .header("Authorization", credential) + .get() + .build() + + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + Result.success(body) + } else { + Result.failure(IOException("Server returned ${response.code}: ${response.message}")) + } + } + } catch (e: Exception) { + Result.failure(e) + } + + fun testConnection( + serverUrl: String, + username: String, + password: String, + ): Result = try { + val client = createClient() + val url = serverUrl.trimEnd('/') + val credential = Credentials.basic(username, password) + + // PROPFIND with Depth: 0 is the standard WebDAV handshake + val request = Request.Builder() + .url(url) + .header("Authorization", credential) + .header("Depth", "0") + .method("PROPFIND", ByteArray(0).toRequestBody(null)) + .build() + + client.newCall(request).execute().use { response -> + when { + response.code == 207 || response.isSuccessful -> Result.success(Unit) + response.code == 401 -> Result.failure(IOException("Authentication failed: wrong username or password")) + response.code == 405 -> Result.success(Unit) // server doesn't support PROPFIND but is reachable + else -> Result.failure(IOException("Server returned ${response.code}: ${response.message}")) + } + } + } 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 7ba7c7e3..fe532e7b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -71,6 +71,7 @@ import com.greenart7c3.nostrsigner.ui.actions.AccountBackupScreen import com.greenart7c3.nostrsigner.ui.actions.AccountsBottomSheet import com.greenart7c3.nostrsigner.ui.actions.ActiveRelaysScreen import com.greenart7c3.nostrsigner.ui.actions.ActivityScreen +import com.greenart7c3.nostrsigner.ui.actions.CloudBackupScreen import com.greenart7c3.nostrsigner.ui.actions.ConnectOrbotScreen import com.greenart7c3.nostrsigner.ui.actions.DefaultRelaysScreen import com.greenart7c3.nostrsigner.ui.actions.ExportAllAccountsScreen @@ -461,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)) @@ -488,6 +490,24 @@ fun MainScreen( }, ) + composable( + Route.CloudBackup.route, + content = { + val scrollState = rememberScrollState() + CloudBackupScreen( + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScrollbar(scrollState) + .verticalScroll(scrollState) + .padding(horizontal = verticalPadding) + .padding(top = verticalPadding * 1.5f) + .imePadding(), + ) + }, + ) + composable( Route.Permission.route, arguments = listOf(navArgument("packageName") { type = NavType.StringType }), 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 27943e0e..2c42528c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/SettingsScreen.kt @@ -16,7 +16,6 @@ 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 @@ -147,20 +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) - }, - ) - } - 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..6609100e 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 @@ -27,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ExpandLess @@ -88,6 +90,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 +103,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 +122,7 @@ import kotlinx.coroutines.launch @Composable fun AccountBackupScreen( modifier: Modifier, + navController: NavController, onShowQrCode: (String) -> Unit, ) { var isLoading by remember { mutableStateOf(false) } @@ -186,6 +192,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 = Icons.Filled.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 = Icons.Filled.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 new file mode 100644 index 00000000..85207bc4 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/CloudBackupScreen.kt @@ -0,0 +1,603 @@ +package com.greenart7c3.nostrsigner.ui.actions + +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +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.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import com.greenart7c3.nostrsigner.Amber +import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.service.AccountExportService +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.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CloudBackupScreen(modifier: Modifier) { + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + + var isLoading by remember { mutableStateOf(false) } + var statusText by remember { mutableStateOf("") } + + // Encryption password shared across both providers + var password by remember { mutableStateOf(TextFieldValue("")) } + var showPassword by remember { mutableStateOf(false) } + var passwordError by remember { mutableStateOf("") } + + // WebDAV state + var webDavUrl by remember { mutableStateOf(TextFieldValue("")) } + var webDavUsername by remember { mutableStateOf(TextFieldValue("")) } + var webDavPassword by remember { mutableStateOf(TextFieldValue("")) } + var showWebDavPassword by remember { mutableStateOf(false) } + var webDavError by remember { mutableStateOf("") } + var webDavRestoreNpub by remember { mutableStateOf(TextFieldValue("")) } + + // Google Drive sheet state + var showGdriveSheet by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + webDavUrl = TextFieldValue(LocalPreferences.getWebDavUrl(context)) + webDavUsername = TextFieldValue(LocalPreferences.getWebDavUsername(context)) + webDavPassword = TextFieldValue(LocalPreferences.getWebDavPassword(context)) + } + } + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + + // Share all ncryptsec pairs via the Android share sheet. + // Writes each to a temp file in cacheDir, gets a FileProvider URI, then fires + // ACTION_SEND / ACTION_SEND_MULTIPLE so the user can pick Google Drive (or any app). + // No file is persisted on-device beyond the cache. + fun shareNcryptsecFiles(pairs: List>) { + val uris = pairs.map { (npub, ncryptsec) -> + val file = File(context.cacheDir, "$npub.ncryptsec") + file.writeText(ncryptsec) + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + } + val shareIntent = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uris[0]) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "text/plain" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.cloud_backup_share_title))) + } + + // Restore: pick any .ncryptsec file and import it + val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { fileUri -> + if (password.text.isBlank()) { + passwordError = context.getString(R.string.cloud_backup_password_required) + return@let + } + Amber.instance.applicationIOScope.launch { + isLoading = true + statusText = context.getString(R.string.cloud_backup_restoring) + val content = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(fileUri)?.bufferedReader()?.readText() ?: "" + } + AccountExportService.importFromNcryptsec( + content = content, + 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 + } + }, + ) + } + } + } + + if (isLoading) { + CenterCircularProgressIndicator(modifier, statusText) + return + } + + 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 + .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(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // --- 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 + } + }, + ) + + 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, + ) + Spacer(modifier = Modifier.height(8.dp)) + + 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() + } + } + }, + ) + + AmberButton( + modifier = Modifier.weight(1f), + text = stringResource(R.string.cloud_backup_webdav_test), + onClick = { + if (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_testing_connection) + val result = withContext(Dispatchers.IO) { + WebDavService.testConnection( + serverUrl = webDavUrl.text, + username = webDavUsername.text, + password = webDavPassword.text, + ) + } + withContext(Dispatchers.Main) { + if (result.isSuccess) { + Toast.makeText(context, context.getString(R.string.cloud_backup_webdav_connected), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, context.getString(R.string.cloud_backup_failed, result.exceptionOrNull()?.message), Toast.LENGTH_LONG).show() + } + isLoading = false + statusText = "" + } + } + } + }, + ) + } + + 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)) + + 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: sends each {npub}.ncryptsec directly via CreateDocument + // (Android system picker — user can choose Google Drive, no local save needed) + 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_sheet_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + + HorizontalDivider() + + // Backup: export all accounts and launch CreateDocument for each, one at a time + 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 -> + withContext(Dispatchers.Main) { + isLoading = false + statusText = "" + if (pairs.isNotEmpty()) { + shareNcryptsecFiles(pairs) + } + } + }.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() + } + }, + ) + }, + ) + + // Restore: user picks a .ncryptsec file from any provider (Google Drive, etc.) + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.cloud_backup_gdrive_restore), + onClick = { + showGdriveSheet = false + filePickerLauncher.launch("*/*") + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/navigation/Route.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/navigation/Route.kt index c523a26f..6eafb33b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/navigation/Route.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/navigation/Route.kt @@ -208,6 +208,12 @@ sealed class Route( route = "UpdateSettings", icon = R.drawable.settings, ) + + data object CloudBackup : Route( + title = Amber.instance.getString(R.string.cloud_backup_title), + route = "CloudBackup", + icon = R.drawable.settings, + ) } val routes = listOf( @@ -241,4 +247,5 @@ val routes = listOf( Route.Activities, Route.CrashReport, Route.UpdateSettings, + Route.CloudBackup, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 920e3d64..620ed2a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -447,6 +447,45 @@ You are about to export %d accounts to an encrypted file. This file will contain all your private keys. Store it securely! Export + Cloud Backup + Encryption Password + This password encrypts your keys before uploading. You will need it to restore. + Password is required to backup or restore + Google Drive + Save each account\'s encrypted key directly to Google Drive. No API key required. + Each account is saved as its own {npub}.ncryptsec file. Android will ask where to save each one — choose Google Drive to send directly without storing anything on this device. + Pick Folder + Change + Open Google Drive + Backup All Accounts + Restore from File + Save encrypted key backup + 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 + Username + WebDAV Password + Backup Filename + Save Settings + WebDAV settings saved + Backup + Restore + Test Connection + Connected successfully! + Server URL is required + Uploading backup… + Downloading backup… + Testing connection… + Restoring accounts… + Backup saved: %s + Backup failed: %s + Accounts restored successfully! To create a new nsecbunker you need to give it a name and select one or more relays, that\'s all! Create Name diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11e3c2a4..c36a9140 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ material3 = "1.4.0" nav_version = "2.9.7" quartz = "1.06.3" compose_ui = "1.10.6" -richtextUi = "a02a03a3e9" +richtextUi = "f92ef49c9d" roomKtx = "2.8.4" securityCryptoKtx = "1.1.0" zxingAndroidEmbedded = "4.3.0"