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