Merge pull request #380 from greenart7c3/claude/encrypted-cloud-key-storage-dKmxZ

Add cloud backup support for WebDAV and Google Drive
This commit is contained in:
greenart7c3
2026-04-08 14:03:43 -03:00
committed by GitHub
10 changed files with 919 additions and 16 deletions
@@ -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)
@@ -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<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 result = mutableListOf<Pair<String, String>>()
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
*
@@ -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<Unit> = 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<String> = 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<Unit> = 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)
}
}
@@ -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 }),
@@ -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),
@@ -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))
}
}
}
@@ -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<Pair<String, String>>) {
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("*/*")
},
)
}
}
}
}
@@ -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,
)
+39
View File
@@ -447,6 +447,45 @@
<string name="confirm_export_message">You are about to export %d accounts to an encrypted file.</string>
<string name="confirm_export_warning">This file will contain all your private keys. Store it securely!</string>
<string name="export">Export</string>
<string name="cloud_backup_title">Cloud Backup</string>
<string name="cloud_backup_encryption_section">Encryption Password</string>
<string name="cloud_backup_encryption_description">This password encrypts your keys before uploading. You will need it to restore.</string>
<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">Save each account\'s encrypted key directly to Google Drive. No API key required.</string>
<string name="cloud_backup_gdrive_sheet_description">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.</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_share_title">Save encrypted key backup</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>
<string name="cloud_backup_webdav_username">Username</string>
<string name="cloud_backup_webdav_password">WebDAV Password</string>
<string name="cloud_backup_webdav_filename">Backup Filename</string>
<string name="cloud_backup_webdav_save">Save Settings</string>
<string name="cloud_backup_webdav_saved">WebDAV settings saved</string>
<string name="cloud_backup_webdav_backup">Backup</string>
<string name="cloud_backup_webdav_restore">Restore</string>
<string name="cloud_backup_webdav_test">Test Connection</string>
<string name="cloud_backup_webdav_connected">Connected successfully!</string>
<string name="cloud_backup_webdav_url_required">Server URL is required</string>
<string name="cloud_backup_uploading">Uploading backup…</string>
<string name="cloud_backup_downloading">Downloading backup…</string>
<string name="cloud_backup_testing_connection">Testing connection…</string>
<string name="cloud_backup_restoring">Restoring accounts…</string>
<string name="cloud_backup_success">Backup saved: %s</string>
<string name="cloud_backup_failed">Backup failed: %s</string>
<string name="cloud_backup_restore_success">Accounts restored successfully!</string>
<string name="create_nsecbunker_description">To create a new nsecbunker you need to give it a name and select one or more relays, that\'s all!</string>
<string name="create">Create</string>
<string name="name">Name</string>
+1 -1
View File
@@ -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"