mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 11:28:22 +00:00
Cloud backup: export/import ncryptsec only (no JSON metadata)
Replace the JSON-lines format with plain ncryptsec lines (NIP-49 encrypted private keys, one per line). Each ncryptsec is self- contained and compatible with any Nostr client that understands NIP-49 — no extra metadata fields needed. - AccountExportService: add exportAllAsNcryptsec() and importFromNcryptsec(); remove importAccountsFromString() - CloudBackupScreen: all backup/restore paths now write and read ncryptsec lines; Google Drive restore reads file content then calls importFromNcryptsec; default filename changed to .txt - LocalPreferences: default WebDAV filename updated to .txt https://claude.ai/code/session_01MCtBrEDEWfCfzppiGRaKEU
This commit is contained in:
@@ -527,7 +527,7 @@ object LocalPreferences {
|
||||
|
||||
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.jsonl") ?: "amber_backup.jsonl"
|
||||
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, "") ?: ""
|
||||
|
||||
@@ -82,9 +82,35 @@ object AccountExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Import accounts from a backup string (used for cloud restore)
|
||||
* Export all accounts as ncryptsec lines (NIP-49 encrypted private keys).
|
||||
* One ncryptsec per line — no JSON metadata, compatible with any Nostr client.
|
||||
*/
|
||||
suspend fun importAccountsFromString(
|
||||
suspend fun exportAllAsNcryptsec(
|
||||
context: Context,
|
||||
password: String,
|
||||
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> },
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val accountInfos = LocalPreferences.allSavedAccounts(context)
|
||||
val total = accountInfos.size
|
||||
if (total == 0) return@withContext Result.failure(Exception("No accounts to export"))
|
||||
|
||||
val lines = StringBuilder()
|
||||
accountInfos.forEachIndexed { index, info ->
|
||||
onProgress(index + 1, total)
|
||||
val account = LocalPreferences.loadFromEncryptedStorage(context, info.npub)
|
||||
account?.let { lines.appendLine(it.nip49Encrypt(password)) }
|
||||
}
|
||||
Result.success(lines.toString().trimEnd())
|
||||
} 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,
|
||||
@@ -93,27 +119,25 @@ object AccountExportService {
|
||||
) {
|
||||
onLoading(true)
|
||||
try {
|
||||
content.lines().filter { it.isNotBlank() }.forEachIndexed { index, line ->
|
||||
content.lines().filter { it.isNotBlank() }.forEachIndexed { index, ncryptsec ->
|
||||
try {
|
||||
onText(Amber.instance.getString(R.string.importing_account, index + 1))
|
||||
|
||||
val accountData = AccountExportData.fromJson(line)
|
||||
val privKey = Nip49().decrypt(accountData.nsec, password)
|
||||
val privKey = Nip49().decrypt(ncryptsec, password)
|
||||
val hexKey = Nip01Crypto.pubKeyCreate(Hex.decode(privKey))
|
||||
val account = Account(
|
||||
hexKey = hexKey.toHexKey(),
|
||||
npub = hexKey.toNpub(),
|
||||
name = MutableStateFlow(accountData.name),
|
||||
picture = MutableStateFlow(accountData.picture ?: ""),
|
||||
signPolicy = accountData.signPolicy,
|
||||
didBackup = accountData.didBackup,
|
||||
name = MutableStateFlow(""),
|
||||
picture = MutableStateFlow(""),
|
||||
signPolicy = 1,
|
||||
didBackup = true,
|
||||
signer = NostrSignerInternal(KeyPair(privKey.hexToByteArray())),
|
||||
)
|
||||
LocalPreferences.switchToAccount(Amber.instance, accountData.npub)
|
||||
LocalPreferences.updatePrefsForLogin(Amber.instance, account, hexKey.toHexKey(), privKey, accountData.seedWords)
|
||||
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 account from string", e)
|
||||
Log.e("Amber", "Error importing ncryptsec at index $index", e)
|
||||
}
|
||||
}
|
||||
onFinish()
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -65,17 +64,18 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val DEFAULT_WEBDAV_FILENAME = "amber_backup.txt"
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun CloudBackupScreen(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
// Shared state
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var statusText by remember { mutableStateOf("") }
|
||||
|
||||
// Password for encryption/decryption (shared across both providers)
|
||||
// Encryption password shared across both providers
|
||||
var password by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var passwordError by remember { mutableStateOf("") }
|
||||
@@ -85,7 +85,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
var webDavUsername by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var webDavPassword by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showWebDavPassword by remember { mutableStateOf(false) }
|
||||
var webDavFilename by remember { mutableStateOf(TextFieldValue("amber_backup.jsonl")) }
|
||||
var webDavFilename by remember { mutableStateOf(TextFieldValue(DEFAULT_WEBDAV_FILENAME)) }
|
||||
var webDavError by remember { mutableStateOf("") }
|
||||
|
||||
// Google Drive state
|
||||
@@ -108,9 +108,8 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric launcher (shared)
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _: ActivityResult -> }
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||||
|
||||
// Google Drive: pick folder
|
||||
val folderPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
@@ -127,9 +126,9 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Google Drive: pick a backup file for restore
|
||||
// Google Drive: pick a backup file for restore (reads ncryptsec lines)
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let {
|
||||
uri?.let { fileUri ->
|
||||
if (password.text.isBlank()) {
|
||||
passwordError = context.getString(R.string.cloud_backup_password_required)
|
||||
return@let
|
||||
@@ -137,8 +136,11 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
Amber.instance.applicationIOScope.launch {
|
||||
isLoading = true
|
||||
statusText = context.getString(R.string.cloud_backup_restoring)
|
||||
AccountExportService.importAccounts(
|
||||
uri = it,
|
||||
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 },
|
||||
@@ -196,7 +198,11 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
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),
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.hide_password)
|
||||
} else {
|
||||
stringResource(R.string.show_password)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -233,7 +239,11 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
AmberButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(
|
||||
if (gdriveFolderUri == null) R.string.cloud_backup_gdrive_pick_folder else R.string.cloud_backup_gdrive_change_folder,
|
||||
if (gdriveFolderUri == null) {
|
||||
R.string.cloud_backup_gdrive_pick_folder
|
||||
} else {
|
||||
R.string.cloud_backup_gdrive_change_folder
|
||||
},
|
||||
),
|
||||
onClick = { folderPickerLauncher.launch(null) },
|
||||
)
|
||||
@@ -259,7 +269,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
Amber.instance.applicationIOScope.launch {
|
||||
isLoading = true
|
||||
statusText = context.getString(R.string.preparing_export)
|
||||
val result = AccountExportService.exportAllAccountsEncrypted(
|
||||
val result = AccountExportService.exportAllAsNcryptsec(
|
||||
context = context,
|
||||
password = password.text,
|
||||
onProgress = { cur, tot ->
|
||||
@@ -269,7 +279,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
result.onSuccess { data ->
|
||||
try {
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val fileName = "amber_backup_$timestamp.jsonl"
|
||||
val fileName = "amber_backup_$timestamp.txt"
|
||||
val folderUri = gdriveFolderUri!!
|
||||
val docUri = DocumentsContract.createDocument(
|
||||
context.contentResolver,
|
||||
@@ -277,7 +287,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
folderUri,
|
||||
DocumentsContract.getTreeDocumentId(folderUri),
|
||||
),
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
fileName,
|
||||
)
|
||||
docUri?.let { fileUri ->
|
||||
@@ -384,7 +394,11 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
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),
|
||||
contentDescription = if (showWebDavPassword) {
|
||||
stringResource(R.string.hide_password)
|
||||
} else {
|
||||
stringResource(R.string.show_password)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -410,7 +424,6 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
Text(text = webDavError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
// Save WebDAV settings button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -426,7 +439,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
url = webDavUrl.text,
|
||||
username = webDavUsername.text,
|
||||
password = webDavPassword.text,
|
||||
fileName = webDavFilename.text.ifBlank { "amber_backup.jsonl" },
|
||||
fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME },
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, context.getString(R.string.cloud_backup_webdav_saved), Toast.LENGTH_SHORT).show()
|
||||
@@ -458,16 +471,15 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
isLoading = true
|
||||
statusText = context.getString(R.string.preparing_export)
|
||||
|
||||
// Save settings first
|
||||
LocalPreferences.saveWebDavSettings(
|
||||
context = context,
|
||||
url = webDavUrl.text,
|
||||
username = webDavUsername.text,
|
||||
password = webDavPassword.text,
|
||||
fileName = webDavFilename.text.ifBlank { "amber_backup.jsonl" },
|
||||
fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME },
|
||||
)
|
||||
|
||||
val exportResult = AccountExportService.exportAllAccountsEncrypted(
|
||||
val exportResult = AccountExportService.exportAllAsNcryptsec(
|
||||
context = context,
|
||||
password = password.text,
|
||||
onProgress = { cur, tot ->
|
||||
@@ -482,7 +494,7 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
serverUrl = webDavUrl.text,
|
||||
username = webDavUsername.text,
|
||||
password = webDavPassword.text,
|
||||
fileName = webDavFilename.text.ifBlank { "amber_backup.jsonl" },
|
||||
fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME },
|
||||
content = data,
|
||||
)
|
||||
}
|
||||
@@ -532,12 +544,12 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
serverUrl = webDavUrl.text,
|
||||
username = webDavUsername.text,
|
||||
password = webDavPassword.text,
|
||||
fileName = webDavFilename.text.ifBlank { "amber_backup.jsonl" },
|
||||
fileName = webDavFilename.text.ifBlank { DEFAULT_WEBDAV_FILENAME },
|
||||
)
|
||||
}
|
||||
|
||||
downloadResult.onSuccess { data ->
|
||||
AccountExportService.importAccountsFromString(
|
||||
AccountExportService.importFromNcryptsec(
|
||||
content = data,
|
||||
password = password.text,
|
||||
onLoading = { loading -> isLoading = loading },
|
||||
@@ -564,7 +576,6 @@ fun CloudBackupScreen(modifier: Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
// Test connection button
|
||||
AmberButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.cloud_backup_webdav_test),
|
||||
|
||||
Reference in New Issue
Block a user