From dcb0fdf2d14d39ef4df372f00679cce97f840561 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 10:40:07 +0000 Subject: [PATCH 1/2] Split applications backup setting into per-account Make the automatic applications-backup flag per-account instead of a single global toggle, and redesign the screen to list every account with its own switch plus per-account "Backup now" / "Restore" actions. The WorkManager job now backs up only accounts whose own flag is enabled, the alarm is scheduled when any account opts in, and the sign-in restore prompt is gated per account. Existing users default to off (no migration of the old global flag). https://claude.ai/code/session_01Gxg5hLcqHqMXCMzJM44E9H --- .../java/com/greenart7c3/nostrsigner/Amber.kt | 2 +- .../nostrsigner/LocalPreferences.kt | 25 ++- .../nostrsigner/models/AmberSettings.kt | 1 - .../service/BackupApplicationsWorker.kt | 4 +- .../nostrsigner/ui/AccountStateViewModel.kt | 2 +- .../ui/ApplicationsBackupScreen.kt | 205 +++++++++++------- .../greenart7c3/nostrsigner/ui/MainScreen.kt | 1 - 7 files changed, 140 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index a8637693..6535588d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -312,7 +312,7 @@ class Amber : fun startBackupApplicationsAlarm() { if (BuildFlavorChecker.isOfflineFlavor()) return - if (!settings.backupApplications) return + if (!LocalPreferences.anyAccountBackupEnabled(this)) return val workManager = WorkManager.getInstance(this) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt index 60f555c8..7a6455f3 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/LocalPreferences.kt @@ -38,6 +38,7 @@ private enum class PrefKeys(val key: String) { LAST_METADATA_UPDATE("last_metadata_update"), LAST_CHECK("last_check"), DID_BACKUP("did_backup"), + BACKUP_APPLICATIONS("backup_applications"), } private enum class SettingsKeys(val key: String) { @@ -71,7 +72,6 @@ private enum class SettingsKeys(val key: String) { RATE_LIMIT_ENABLED("rate_limit_enabled"), RATE_LIMIT_MAX_PER_WINDOW("rate_limit_max_per_window"), RATE_LIMIT_WINDOW_SECONDS("rate_limit_window_seconds"), - BACKUP_APPLICATIONS("backup_applications"), } @Immutable @@ -150,7 +150,6 @@ object LocalPreferences { putBoolean(SettingsKeys.RATE_LIMIT_ENABLED.key, settings.rateLimitEnabled) putInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, settings.rateLimitMaxPerWindow) putInt(SettingsKeys.RATE_LIMIT_WINDOW_SECONDS.key, settings.rateLimitWindowSeconds) - putBoolean(SettingsKeys.BACKUP_APPLICATIONS.key, settings.backupApplications) } } } @@ -165,6 +164,18 @@ object LocalPreferences { } } + fun getBackupApplications(context: Context, npub: String): Boolean = sharedPrefs(context, npub).getBoolean(PrefKeys.BACKUP_APPLICATIONS.key, false) + + fun setBackupApplications(context: Context, npub: String, enabled: Boolean) { + sharedPrefs(context, npub).edit { + apply { + putBoolean(PrefKeys.BACKUP_APPLICATIONS.key, enabled) + } + } + } + + fun anyAccountBackupEnabled(context: Context): Boolean = allSavedAccounts(context).any { getBackupApplications(context, it.npub) } + fun getLastCheck(context: Context, npub: String): Long = sharedPrefs(context, npub).getLong(PrefKeys.LAST_CHECK.key, 0) fun setLastCheck(context: Context, npub: String, time: Long) { @@ -279,7 +290,6 @@ object LocalPreferences { rateLimitEnabled = getBoolean(SettingsKeys.RATE_LIMIT_ENABLED.key, true), rateLimitMaxPerWindow = getInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, 5), rateLimitWindowSeconds = getInt(SettingsKeys.RATE_LIMIT_WINDOW_SECONDS.key, 30), - backupApplications = getBoolean(SettingsKeys.BACKUP_APPLICATIONS.key, false), ) } } @@ -542,15 +552,6 @@ object LocalPreferences { Amber.instance.settings = loadSettingsFromEncryptedStorage() } - fun updateBackupApplications(context: Context, enabled: Boolean) { - sharedPrefs(context).edit { - apply { - putBoolean(SettingsKeys.BACKUP_APPLICATIONS.key, enabled) - } - } - Amber.instance.settings = loadSettingsFromEncryptedStorage() - } - fun updateUpdateCheckFrequency(context: Context, frequency: UpdateCheckFrequency) { sharedPrefs(context).edit { apply { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt index 207205a1..40084a59 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/AmberSettings.kt @@ -37,7 +37,6 @@ data class AmberSettings( val rateLimitEnabled: Boolean = true, val rateLimitMaxPerWindow: Int = 5, val rateLimitWindowSeconds: Int = 30, - val backupApplications: Boolean = false, ) { val useProxy: Boolean get() = torMode != TorMode.DISABLED } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BackupApplicationsWorker.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BackupApplicationsWorker.kt index af4c833f..cb6b2df8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BackupApplicationsWorker.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BackupApplicationsWorker.kt @@ -14,10 +14,8 @@ class BackupApplicationsWorker(appContext: Context, workerParams: WorkerParamete override suspend fun doWork(): Result { if (BuildFlavorChecker.isOfflineFlavor()) return Result.success() - val amber = Amber.instance - if (!amber.settings.backupApplications) return Result.success() - LocalPreferences.allSavedAccounts(applicationContext).forEach { info -> + if (!LocalPreferences.getBackupApplications(applicationContext, info.npub)) return@forEach try { val account = LocalPreferences.loadFromEncryptedStorage(applicationContext, info.npub) ?: return@forEach ApplicationBackup.publishBackup(info.npub, account) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt index 103fb17f..fadece8b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt @@ -200,7 +200,7 @@ class AccountStateViewModel(npub: String?) : ViewModel() { fun maybeOfferRestore(account: Account) { if (BuildFlavorChecker.isOfflineFlavor()) return - if (!Amber.instance.settings.backupApplications) return + if (!LocalPreferences.getBackupApplications(Amber.instance, account.npub)) return Amber.instance.applicationIOScope.launch { try { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt index 3ba9b968..015e193d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt @@ -1,7 +1,6 @@ package com.greenart7c3.nostrsigner.ui import android.widget.Toast -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -18,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -33,6 +35,7 @@ import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.service.ApplicationBackup import com.greenart7c3.nostrsigner.service.RestoreResult +import com.greenart7c3.nostrsigner.service.toShortenHex import com.greenart7c3.nostrsigner.ui.components.AmberButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -41,102 +44,58 @@ import kotlinx.coroutines.withContext @Composable fun ApplicationsBackupScreen( modifier: Modifier = Modifier, - account: Account, ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - var backupApplications by remember { mutableStateOf(Amber.instance.settings.backupApplications) } + + val accounts by produceState?>(initialValue = null) { + value = withContext(Dispatchers.IO) { LocalPreferences.allAccounts(context) } + } + + val loadedAccounts = accounts var isBusy by remember { mutableStateOf(false) } - var showRestoreConfirm by remember { mutableStateOf(false) } + var pendingRestore by remember { mutableStateOf(null) } Column(modifier = modifier) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { - val newValue = !backupApplications - backupApplications = newValue - scope.launch(Dispatchers.IO) { - LocalPreferences.updateBackupApplications(context, newValue) - if (newValue) { - Amber.instance.startBackupApplicationsAlarm() - } else { - Amber.instance.cancelBackupApplicationsAlarm() - } - } - }, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = stringResource(R.string.enable_applications_backup)) - Text( - text = stringResource(R.string.applications_backup_explainer), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - ) - } - Switch( - checked = backupApplications, - onCheckedChange = { enabled -> - backupApplications = enabled - scope.launch(Dispatchers.IO) { - LocalPreferences.updateBackupApplications(context, enabled) - if (enabled) { - Amber.instance.startBackupApplicationsAlarm() - } else { - Amber.instance.cancelBackupApplicationsAlarm() - } - } - }, - ) - } + Text( + text = stringResource(R.string.applications_backup_explainer), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) Spacer(modifier = Modifier.height(16.dp)) - AmberButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.backup_now), - enabled = !isBusy, - onClick = { - if (isBusy) return@AmberButton - isBusy = true - scope.launch(Dispatchers.IO) { - val ok = ApplicationBackup.publishBackup(account.npub, account) - withContext(Dispatchers.Main) { - Toast.makeText( - context, - if (ok) context.getString(R.string.backup_success) else context.getString(R.string.backup_failed), - Toast.LENGTH_LONG, - ).show() - isBusy = false - } + if (loadedAccounts == null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } else { + loadedAccounts.forEachIndexed { index, account -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) } - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - AmberButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.restore_from_relays), - enabled = !isBusy, - onClick = { - if (!isBusy) showRestoreConfirm = true - }, - ) + AccountBackupRow( + account = account, + isBusy = isBusy, + onBusyChange = { isBusy = it }, + onRequestRestore = { pendingRestore = account }, + ) + } + } } - if (showRestoreConfirm) { + pendingRestore?.let { account -> AlertDialog( - onDismissRequest = { showRestoreConfirm = false }, + onDismissRequest = { pendingRestore = null }, title = { Text(stringResource(R.string.restore_confirm_title)) }, text = { Text(stringResource(R.string.restore_confirm_text)) }, confirmButton = { TextButton(onClick = { - showRestoreConfirm = false + pendingRestore = null isBusy = true + val scope = Amber.instance.applicationIOScope scope.launch(Dispatchers.IO) { val result = ApplicationBackup.restoreFromRelays(account.npub, account) withContext(Dispatchers.Main) { @@ -152,7 +111,91 @@ fun ApplicationsBackupScreen( }) { Text(stringResource(R.string.yes)) } }, dismissButton = { - TextButton(onClick = { showRestoreConfirm = false }) { Text(stringResource(R.string.no)) } + TextButton(onClick = { pendingRestore = null }) { Text(stringResource(R.string.no)) } + }, + ) + } +} + +@Composable +private fun AccountBackupRow( + account: Account, + isBusy: Boolean, + onBusyChange: (Boolean) -> Unit, + onRequestRestore: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val name by account.name.collectAsState() + var backupEnabled by remember(account.npub) { + mutableStateOf(LocalPreferences.getBackupApplications(context, account.npub)) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + if (name.isNotBlank()) { + Text(text = name) + } + Text( + text = account.npub.toShortenHex(), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + Switch( + checked = backupEnabled, + onCheckedChange = { enabled -> + backupEnabled = enabled + scope.launch(Dispatchers.IO) { + LocalPreferences.setBackupApplications(context, account.npub, enabled) + if (LocalPreferences.anyAccountBackupEnabled(context)) { + Amber.instance.startBackupApplicationsAlarm() + } else { + Amber.instance.cancelBackupApplicationsAlarm() + } + } + }, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.backup_now), + enabled = !isBusy, + onClick = { + if (isBusy) return@AmberButton + onBusyChange(true) + scope.launch(Dispatchers.IO) { + val ok = ApplicationBackup.publishBackup(account.npub, account) + withContext(Dispatchers.Main) { + Toast.makeText( + context, + if (ok) context.getString(R.string.backup_success) else context.getString(R.string.backup_failed), + Toast.LENGTH_LONG, + ).show() + onBusyChange(false) + } + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + AmberButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.restore_from_relays), + enabled = !isBusy, + onClick = { + if (!isBusy) onRequestRestore() }, ) } 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 07d047a6..f72487bb 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -1080,7 +1080,6 @@ fun MainScreen( .verticalScroll(scrollState) .padding(horizontal = verticalPadding) .padding(top = verticalPadding * 1.5f), - account = account, ) }, ) From 951c7e1432e02021bcb9ac22d2780581b6cfd607 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 10:45:34 +0000 Subject: [PATCH 2/2] Show account picture in applications backup rows Display each account's profile avatar next to its name in the per-account applications backup list, falling back to a Person icon (and skipping network image loading in the offline flavor). https://claude.ai/code/session_01Gxg5hLcqHqMXCMzJM44E9H --- .../ui/ApplicationsBackupScreen.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt index 015e193d..ffb70bf7 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt @@ -1,6 +1,7 @@ package com.greenart7c3.nostrsigner.ui import android.widget.Toast +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,9 +9,16 @@ 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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -25,11 +33,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage import com.greenart7c3.nostrsigner.Amber +import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.models.Account @@ -37,6 +48,7 @@ import com.greenart7c3.nostrsigner.service.ApplicationBackup import com.greenart7c3.nostrsigner.service.RestoreResult import com.greenart7c3.nostrsigner.service.toShortenHex import com.greenart7c3.nostrsigner.ui.components.AmberButton +import com.greenart7c3.nostrsigner.ui.theme.fromHex import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -127,6 +139,7 @@ private fun AccountBackupRow( val context = LocalContext.current val scope = rememberCoroutineScope() val name by account.name.collectAsState() + val picture by account.picture.collectAsState() var backupEnabled by remember(account.npub) { mutableStateOf(LocalPreferences.getBackupApplications(context, account.npub)) } @@ -139,6 +152,8 @@ private fun AccountBackupRow( .fillMaxWidth() .padding(vertical = 4.dp), ) { + AccountAvatar(account = account, picture = picture) + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { if (name.isNotBlank()) { Text(text = name) @@ -201,6 +216,37 @@ private fun AccountBackupRow( } } +@Composable +private fun AccountAvatar( + account: Account, + picture: String, +) { + val borderColor = Color.fromHex(account.hexKey.slice(0..5)) + val fallback: @Composable () -> Unit = { + Icon( + Icons.Outlined.Person, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .border(2.dp, borderColor, CircleShape), + ) + } + + if (picture.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) { + SubcomposeAsyncImage( + model = picture, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .size(40.dp), + loading = { CenterCircularProgressIndicator(Modifier) }, + error = { fallback() }, + ) + } else { + fallback() + } +} + @Composable fun RestoreBackupDialog(accountStateViewModel: AccountStateViewModel) { val state by accountStateViewModel.restorePrompt.collectAsState()