diff --git a/CLAUDE.md b/CLAUDE.md index 9a1c032d..da2674e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` — 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 diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index d0c2508c..e552a33b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -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() } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt index 1e891bf6..b246f639 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ProfileSubscription.kt @@ -54,6 +54,10 @@ class ProfileSubscription( private val relaysPerSubId = mutableMapOf>() private val timeoutJobs = mutableMapOf() + // 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() + 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> { 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 2db44810..0bc72962 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt @@ -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 } 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 ffb70bf7..a9e43104 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ApplicationsBackupScreen.kt @@ -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) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt index efcfe57c..6e68f2d0 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt @@ -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() } }, 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 f72487bb..73c1ff23 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/MainScreen.kt @@ -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) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt index 18264f60..01dd88f0 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountBackupScreen.kt @@ -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) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt index da5b5328..2582c8ca 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/actions/AccountsBottomSheet.kt @@ -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(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)) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LoginWithPubKey.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LoginWithPubKey.kt index 700446b3..126945e7 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LoginWithPubKey.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/LoginWithPubKey.kt @@ -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( diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/ProfileSubscriptionEffect.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/ProfileSubscriptionEffect.kt new file mode 100644 index 00000000..a8c39c03 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/ProfileSubscriptionEffect.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SigningAs.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SigningAs.kt index 95bb2bdf..b1e45234 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SigningAs.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SigningAs.kt @@ -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()