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
This commit is contained in:
Claude
2026-05-25 10:40:07 +00:00
parent 4e26bfad2b
commit dcb0fdf2d1
7 changed files with 140 additions and 100 deletions
@@ -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)
@@ -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 {
@@ -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
}
@@ -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)
@@ -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 {
@@ -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<List<Account>?>(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<Account?>(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()
},
)
}
@@ -1080,7 +1080,6 @@ fun MainScreen(
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
account = account,
)
},
)