Merge pull request #446 from greenart7c3/claude/relaxed-heisenberg-CrLGt

Make application backup per-account instead of global
This commit is contained in:
greenart7c3
2026-05-25 07:53:32 -03:00
committed by GitHub
7 changed files with 186 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,7 @@
package com.greenart7c3.nostrsigner.ui
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,7 +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
@@ -18,22 +27,28 @@ 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
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
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
@@ -41,102 +56,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,12 +123,130 @@ 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()
val picture by account.picture.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),
) {
AccountAvatar(account = account, picture = picture)
Spacer(modifier = Modifier.width(12.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()
},
)
}
}
@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()
@@ -1080,7 +1080,6 @@ fun MainScreen(
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
account = account,
)
},
)