Scope profile subscriptions by account, driven by composables

Profile metadata (kind 0) was fetched by a single app-wide
ProfileSubscription that only ever tracked the current logged-in
account and was started/stopped by the process lifecycle. Composables
showing other accounts (account switcher, login picker, backup screens)
only read stale data from SharedPreferences and never refreshed.

Make ProfileSubscription account-scoped: updateFilter(account) /
closeSub(account) track per-account state in a hexKey->Account map so
incoming events update the matching cached account's StateFlows.
Composables now start/stop their own fetch via a new
ProfileSubscriptionEffect, and AccountsBottomSheet loads real Account
objects so refreshed metadata shows live. Remove the app-wide
foreground/background driver and the scattered current-account
updateFilter() calls; relay-list changes now refresh all tracked
accounts via updateFilters().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uUuFVywrRuSNKNKsK32Pv
This commit is contained in:
Claude
2026-06-29 11:56:36 +00:00
parent 293491f9ba
commit 316adb34d7
12 changed files with 83 additions and 42 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ All three paths converge on `Account.sign()` / encrypt/decrypt methods backed by
- `applicationIOScope` — `CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)`, used for all background work
- `client: NostrClient` — the Quartz Nostr relay client
- `notificationSubscription` — keeps the NIP-46 filter alive in the background
- `profileSubscription` — active only in the foreground (paused in background to save battery)
- `profileSubscription` — per-account, throttled one-shot metadata (kind 0) fetch; started/stopped by the composables that display each account via `ProfileSubscriptionEffect` (not app-wide)
- `isStartingAppState: MutableStateFlow<Boolean>` — set to `true` during `runMigrations()`; code that must wait for startup calls `isStartingAppState.first { !it }`
- `settings.killSwitch` — when true, all relays are disconnected; checked before every relay operation
@@ -139,7 +139,8 @@ class Amber :
val notificationSubscription = NotificationSubscription(client, this)
// This runs on the foreground only
// Per-account profile metadata fetch, started/stopped by the composables that display
// each account (see ProfileSubscriptionEffect) rather than app-wide.
val profileSubscription = ProfileSubscription(client, this, applicationIOScope)
val zapstoreUpdater: ZapstoreUpdater? = if (!BuildFlavorChecker.isOfflineFlavor() && !BuildConfig.IS_FDROID_BUILD) {
@@ -161,13 +162,9 @@ class Amber :
AmberLog.d("ProcessLifecycleOwner", "App in foreground")
isAppInForeground = true
// activates the profile filter only when the app is in the foreground
if (!settings.killSwitch.value) {
if (!settings.killSwitch.value && settings.autoCheckUpdates) {
applicationIOScope.launch {
profileSubscription.updateFilter()
if (settings.autoCheckUpdates) {
maybeCheckForUpdates()
}
maybeCheckForUpdates()
}
}
}
@@ -175,11 +172,6 @@ class Amber :
override fun onStop(owner: LifecycleOwner) {
AmberLog.d("ProcessLifecycleOwner", "App in background")
isAppInForeground = false
// closes the filter when in the background
applicationIOScope.launch {
profileSubscription.closeSub()
}
}
}
@@ -547,7 +539,7 @@ class Amber :
// these update the relay list in the filters and send them to the
// relay, reconnecting if needed
if (isAppInForeground) {
profileSubscription.updateFilter()
profileSubscription.updateFilters()
}
notificationSubscription.updateFilter()
}
@@ -54,6 +54,10 @@ class ProfileSubscription(
private val relaysPerSubId = mutableMapOf<String, MutableSet<NormalizedRelayUrl>>()
private val timeoutJobs = mutableMapOf<String, Job>()
// hexKey -> the cached Account instance a live composable cares about. Updating the
// StateFlows on these instances is what reflects fresh metadata in the UI.
private val accounts = mutableMapOf<String, Account>()
init {
// listens until the app crashes.
client.addConnectionListener(this)
@@ -73,9 +77,10 @@ class ProfileSubscription(
}
}
scope.launch {
val account = LocalPreferences.loadFromEncryptedStorage(appContext) ?: return@launch
if (msg.subId == subIds[account.hexKey]) {
val hexKey = subIds.entries.firstOrNull { it.value == subId }?.key
val account = hexKey?.let { accounts[it] }
if (account != null) {
scope.launch {
LocalPreferences.setLastCheck(Amber.instance, account.npub, TimeUtils.now())
}
}
@@ -83,8 +88,7 @@ class ProfileSubscription(
if (msg is EventMessage) {
if (this.subIds.containsValue(msg.subId)) {
if (msg.event.kind == MetadataEvent.KIND && msg.event.verify()) {
val account = LocalPreferences.loadFromEncryptedStorageSync(appContext) ?: return
if (account.hexKey != msg.event.pubKey) return
val account = accounts[msg.event.pubKey] ?: return
(msg.event as MetadataEvent).contactMetaData()?.let { metadata ->
val npub = account.npub
@@ -117,20 +121,14 @@ class ProfileSubscription(
}
/**
* Call this method every time the relay list or the user list changes
* Starts (or refreshes) the throttled, one-shot metadata fetch for [account].
* Tracks the account so incoming events update its StateFlows; safe to call from
* any composable displaying the account.
*/
suspend fun updateFilter() {
suspend fun updateFilter(account: Account) {
if (BuildFlavorChecker.isOfflineFlavor()) return
val account = LocalPreferences.loadFromEncryptedStorage(appContext) ?: return
subIds.keys.filter { it != account.hexKey }.forEach {
val subId = subIds.remove(it)
if (subId != null) {
timeoutJobs.remove(subId)?.cancel()
client.unsubscribe(subId)
relaysPerSubId.remove(subId)
}
}
accounts[account.hexKey] = account
if (!subIds.containsKey(account.hexKey)) {
subIds[account.hexKey] = UUID.randomUUID().toString()
@@ -157,7 +155,26 @@ class ProfileSubscription(
}
/**
* Call this function when you want to stop updates
* Re-runs the fetch for every currently tracked account. Call when the relay list changes.
*/
suspend fun updateFilters() {
accounts.values.toList().forEach { updateFilter(it) }
}
/**
* Stops updates for a single [account] (call when the composable leaves composition).
*/
fun closeSub(account: Account) {
accounts.remove(account.hexKey)
val subId = subIds.remove(account.hexKey) ?: return
timeoutJobs.remove(subId)?.cancel()
Amber.instance.intentionalDisconnectTime = System.currentTimeMillis()
client.unsubscribe(subId)
relaysPerSubId.remove(subId)
}
/**
* Stops updates for all accounts (e.g. after a backup restore).
*/
fun closeSub() {
Amber.instance.intentionalDisconnectTime = System.currentTimeMillis()
@@ -167,6 +184,7 @@ class ProfileSubscription(
}
relaysPerSubId.clear()
subIds.clear()
accounts.clear()
}
private fun createProfileFilter(account: Account): Map<NormalizedRelayUrl, List<Filter>> {
@@ -63,7 +63,6 @@ class AccountStateViewModel(npub: String?) : ViewModel() {
}
LocalPreferences.loadFromEncryptedStorageSync(Amber.instance, currentUser)?.let {
startUI(it, route)
Amber.instance.profileSubscription.updateFilter()
}
if (currentUser == null) {
_accountContent.update { AccountState.LoggedOff }
@@ -48,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.components.ProfileSubscriptionEffect
import com.greenart7c3.nostrsigner.ui.theme.fromHex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -138,6 +139,7 @@ private fun AccountBackupRow(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
ProfileSubscriptionEffect(account)
val name by account.name.collectAsState()
val picture by account.picture.collectAsState()
var backupEnabled by remember(account.npub) {
@@ -171,7 +171,6 @@ fun MainPage(
}
}
Amber.instance.notificationSubscription.updateFilter()
Amber.instance.profileSubscription.updateFilter()
}
},
)
@@ -379,7 +378,6 @@ fun MainLoginPage(
navHostControllerWrapper = navHostControllerWrapper,
onFinish = {
Amber.instance.applicationIOScope.launch {
Amber.instance.profileSubscription.updateFilter()
Amber.instance.notificationSubscription.updateFilter()
}
},
@@ -82,6 +82,7 @@ import com.greenart7c3.nostrsigner.ui.actions.RelayLogScreen
import com.greenart7c3.nostrsigner.ui.components.AmberBottomBar
import com.greenart7c3.nostrsigner.ui.components.AmberFloatingButton
import com.greenart7c3.nostrsigner.ui.components.AmberTopAppBar
import com.greenart7c3.nostrsigner.ui.components.ProfileSubscriptionEffect
import com.greenart7c3.nostrsigner.ui.navigation.Route
import java.util.Base64
import kotlinx.collections.immutable.ImmutableList
@@ -247,6 +248,9 @@ fun MainScreen(
val items = listOf(Route.Applications, Route.IncomingRequest, Route.Settings, Route.Accounts)
// Keep the current account's profile metadata fresh while the main UI is shown.
ProfileSubscriptionEffect(account)
var isLoading by remember { mutableStateOf(false) }
val shouldShowBottomSheet = remember { mutableStateOf(false) }
@@ -339,7 +343,6 @@ fun MainScreen(
navHostControllerWrapper = navController,
onFinish = {
Amber.instance.applicationIOScope.launch {
Amber.instance.profileSubscription.updateFilter()
Amber.instance.notificationSubscription.updateFilter()
}
navController.navController.navigate(Route.Applications.route) {
@@ -103,6 +103,7 @@ 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.MarkdownText
import com.greenart7c3.nostrsigner.ui.components.ProfileSubscriptionEffect
import com.greenart7c3.nostrsigner.ui.components.SeedWordsPage
import com.greenart7c3.nostrsigner.ui.navigation.Route
import com.greenart7c3.nostrsigner.ui.setSensitiveClip
@@ -341,6 +342,7 @@ private fun AccountBackupCard(
onLoading: (Boolean) -> Unit,
onShowQrCode: (String) -> Unit,
) {
ProfileSubscriptionEffect(account)
val profileUrl = account.picture.collectAsState()
val name = account.name.collectAsState()
var didBackup by remember { mutableStateOf(account.didBackup) }
@@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.SubcomposeAsyncImage
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
@@ -53,6 +54,7 @@ import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.NavHostControllerWrapper
import com.greenart7c3.nostrsigner.ui.components.ActiveMarker
import com.greenart7c3.nostrsigner.ui.components.ProfileSubscriptionEffect
import com.greenart7c3.nostrsigner.ui.navigation.Route
import com.greenart7c3.nostrsigner.ui.theme.fromHex
import com.greenart7c3.nostrsigner.ui.verticalScrollbar
@@ -105,16 +107,17 @@ fun AccountsBottomSheet(
Text(stringResource(R.string.select_account), fontWeight = FontWeight.Bold)
}
accounts.forEach { acc ->
// Reading the name/picture from SharedPreferences touches disk, so do it
// off the main thread instead of synchronously during composition.
val accountInfo by produceState("" to "", acc.npub) {
// Load the cached Account off the main thread (decrypts once, then cached) so we
// can observe its name/picture StateFlows and refresh its metadata while the
// sheet is open.
val loadedAccount by produceState<Account?>(null, acc.npub) {
value = withContext(Dispatchers.IO) {
LocalPreferences.getAccountName(context, acc.npub) to
LocalPreferences.getProfileUrl(context, acc.npub)
LocalPreferences.loadFromEncryptedStorage(context, acc.npub)
}
}
val name = accountInfo.first
val profileUrl = accountInfo.second
loadedAccount?.let { ProfileSubscriptionEffect(it) }
val name = loadedAccount?.name?.collectAsStateWithLifecycle()?.value ?: ""
val profileUrl = loadedAccount?.picture?.collectAsStateWithLifecycle()?.value ?: ""
val borderColor = remember(acc.npub) {
Color.fromHex(acc.npub.bechToBytes().toHexKey().slice(0..5))
}
@@ -69,6 +69,7 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun ProfilePictureIcon(account: Account) {
ProfileSubscriptionEffect(account)
val profileUrl by account.picture.collectAsStateWithLifecycle()
if (profileUrl.isNotBlank() && !BuildFlavorChecker.isOfflineFlavor()) {
AsyncImage(
@@ -0,0 +1,22 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.models.Account
import kotlinx.coroutines.launch
/**
* Drives the per-account profile metadata fetch from whichever composable is displaying
* [account]. Starts (or refreshes) the throttled, one-shot subscription when the composable
* enters composition and closes it on dispose, replacing the old app-wide, current-account-only
* subscription.
*/
@Composable
fun ProfileSubscriptionEffect(account: Account) {
DisposableEffect(account.hexKey) {
val sub = Amber.instance.profileSubscription
Amber.instance.applicationIOScope.launch { sub.updateFilter(account) }
onDispose { sub.closeSub(account) }
}
}
@@ -33,6 +33,7 @@ import com.greenart7c3.nostrsigner.ui.theme.fromHex
@Composable
fun SigningAs(account: Account, modifier: Modifier = Modifier) {
ProfileSubscriptionEffect(account)
Column(
modifier = modifier
.fillMaxWidth()