mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
@@ -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(
|
||||
|
||||
+22
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user