From 61611bdc48db8cb670964a5242dc2a7ccd705a51 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 Jul 2026 09:56:01 -0400 Subject: [PATCH] feat(subscriptions): every account pulls while a screen is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Keep this account active in the background" gated subscriptions everywhere, not just in the background. An account you had not opted in for showed no notifications and no DMs even with the app open in front of you — you had to switch to it and wait for its subscriptions to mount from scratch. The setting's name only ever promised something about being away. So the rule now matches the name. While any activity is STARTED, every loaded account pulls its own notifications, DMs and gift wraps: the user can switch accounts at any moment and expects the one they land on to be current, and this costs nothing once the app is away because it ends with the screen. When the app goes away, the set narrows to the accounts that opted in — which is the only thing the flag decides now. The service layers stay where they were, gated on the master switch AND somebody having opted in. A foreground-only account must never start a foreground service that outlives the screen that wanted it, so those two questions are answered from one snapshot of accounts + flags rather than two. The registry loses "Background" from its name along with the assumption: it mounts exactly the set it is handed and decides nothing, so the rule lives in one place. Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4 mounted, and the account that had no section on the subscriptions screen at all now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted (-1), releasing the one that never opted in. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../AlwaysOnNotificationServiceManager.kt | 164 +++++++++++------- .../account/AccountFilterAssembler.kt | 2 +- ...stry.kt => AccountSubscriptionRegistry.kt} | 53 +++--- 4 files changed, 129 insertions(+), 97 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/{BackgroundAccountSubscriptionRegistry.kt => AccountSubscriptionRegistry.kt} (62%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bb895e81b8..8098fed6e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -95,7 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger @@ -963,7 +963,7 @@ class AppModules( // account-level subscriptions (notifications, DMs, gift wraps, wallet), with no // AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below, // which already tracks which accounts opted in. - val backgroundAccountSubscriptions = BackgroundAccountSubscriptionRegistry(sources.account) + val accountSubscriptions = AccountSubscriptionRegistry(sources.account) // Manages always-on notification service lifecycle. Preloads every saved // writable account while enabled so GiftWraps for non-active accounts still @@ -974,7 +974,8 @@ class AppModules( scope = applicationIOScope, accountsCache = accountsCache, localPreferences = LocalPreferences, - backgroundSubscriptions = backgroundAccountSubscriptions, + subscriptions = accountSubscriptions, + isForeground = foregroundTracker.isForeground, activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 039398c66f..d2bcd9d240 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -22,14 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications import android.content.Context import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.BackgroundAccountSubscriptionRegistry +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -46,32 +49,45 @@ import kotlinx.coroutines.launch * L4 - BootCompletedReceiver (restart on boot) * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) * - * Two switches gate the system: + * It also decides **which accounts pull from relays**, which is a different question from + * whether the service runs, and the two are deliberately not gated the same way. * - * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the - * "Background notification service" toggle / Quick Settings tile). When off, every - * layer is torn down and nothing restarts, regardless of any account's setting — - * this is the battery-saver "airplane mode". Persisted, so an explicit off survives - * restarts and crashes. - * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], - * "Keep this account active in the background") **or** its NIP-46 signer toggle - * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is - * on, the service runs as long as **at least one** writable account has either flag on — the - * background signer relies on the same foreground service to keep answering requests. + * ## Who subscribes * - * While the master is on, every saved writable account is kept loaded in - * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps - * addressed to any of them (delivered via open relay subscriptions) get unwrapped by - * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active - * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no - * subscriber able to decrypt them. + * - **While a screen is up: every loaded account.** The user can switch accounts at any moment + * and expects the one they land on to be current, so all of them keep their own notifications, + * DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen. + * - **While the app is away: only the accounts that opted in**, via + * [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this + * account active in the background") or their NIP-46 signer toggle + * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). + * + * That is what the setting's name promises, and for a while it did not hold: participation gated + * subscriptions everywhere, so an account you had not opted in for showed no notifications even + * with the app open in front of you. + * + * ## Whether the service runs + * + * The five layers are a background concern, so they stay gated on **both** the global master + * ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service" + * toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off + * survives restarts) **and** at least one account having opted in. A foreground-only account must + * never start a foreground service that outlives the screen that wanted it. + * + * Every saved writable account is kept loaded in [AccountCacheState] whenever either condition + * holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get + * unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active + * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to + * decrypt them. */ class AlwaysOnNotificationServiceManager( private val context: Context, private val scope: CoroutineScope, private val accountsCache: AccountCacheState, private val localPreferences: LocalPreferences, - private val backgroundSubscriptions: BackgroundAccountSubscriptionRegistry, + private val subscriptions: AccountSubscriptionRegistry, + /** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */ + private val isForeground: StateFlow, private val activePubKeyProvider: () -> HexKey?, ) { companion object { @@ -100,59 +116,75 @@ class AlwaysOnNotificationServiceManager( wasEnabled = false watchJob = scope.launch { - localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled -> - if (!masterEnabled) { - // Global airplane mode: suppress every layer regardless of - // per-account participation, and stop keeping accounts loaded. - if (wasEnabled) { - disableServiceLayers() - wasEnabled = false - } - stopMultiAccountPreload() - return@collectLatest - } - - // Master on: keep every writable account loaded so its participation - // flag is observable and its gift wraps can decrypt, then run the - // service only while at least one account is participating. An account - // participates when its always-on setting OR its NIP-46 signer toggle is - // on — the background signer needs the same foreground service alive. - startMultiAccountPreload() - accountsCache.accounts - .flatMapLatest { accounts -> - val flags = - accounts.values.map { account -> - account.settings.alwaysOnNotificationService - .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> - if (alwaysOn || signer) account else null - } - } - if (flags.isEmpty()) { - flowOf(emptyList()) - } else { - combine(flags) { values -> values.filterNotNull() } - } - }.distinctUntilChanged() - .collectLatest { participating -> - // Give every participating account its own account-level - // subscriptions (notifications, DMs, gift wraps, wallet). - // Until this existed, only the account on screen pulled - // anything and the rest waited on a push that may never - // come. - backgroundSubscriptions.sync(participating) - - if (participating.isNotEmpty()) { - wasEnabled = true - enableServiceLayers() - } else if (wasEnabled) { + localPreferences + .notificationServiceEnabledFlow() + .combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground } + .collectLatest { (masterEnabled, foreground) -> + if (!masterEnabled && !foreground) { + // Nothing wants the accounts: the master is off and no screen is up. + // Suppress every layer and stop keeping accounts loaded. + if (wasEnabled) { disableServiceLayers() wasEnabled = false } + stopMultiAccountPreload() + return@collectLatest } - } + + // Keep every writable account loaded — in the foreground so they can all + // pull, and with the master on so participation flags are observable and + // gift wraps can decrypt. + startMultiAccountPreload() + + accountsAndParticipants() + .distinctUntilChanged() + .collectLatest { (all, participating) -> + // The rule the "keep this account active in the background" setting + // actually describes: while a screen is up, EVERY loaded account + // pulls its own notifications, DMs and gift wraps, because the user + // can switch to any of them and expects them current. The setting + // only decides which ones keep doing it once the app is away. + subscriptions.sync(if (foreground) all else participating) + + // The service layers are a background concern, so they stay tied to + // the master switch and to somebody having opted in. A foreground-only + // account must not start a foreground service that outlives the screen. + if (masterEnabled && participating.isNotEmpty()) { + wasEnabled = true + enableServiceLayers() + } else if (wasEnabled) { + disableServiceLayers() + wasEnabled = false + } + } + } } } + /** + * Every loaded account paired with the subset that opted into running in the background. + * + * Both come from one flow because they change together and the two decisions below — who + * subscribes, and whether the service runs — must never be made from different snapshots. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun accountsAndParticipants(): Flow, List>> = + accountsCache.accounts.flatMapLatest { accounts -> + val all = accounts.values.toList() + val flags = + all.map { account -> + account.settings.alwaysOnNotificationService + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> + if (alwaysOn || signer) account else null + } + } + if (flags.isEmpty()) { + flowOf(all to emptyList()) + } else { + combine(flags) { values -> all to values.filterNotNull() } + } + } + fun stop() { watchJob?.cancel() watchJob = null @@ -229,7 +261,7 @@ class AlwaysOnNotificationServiceManager( private fun stopMultiAccountPreload() { preloadJob?.cancel() preloadJob = null - backgroundSubscriptions.clear() + subscriptions.clear() // remove this because we don't know which other accounts might be getting used. // val active = activePubKeyProvider() // if (active != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index e26fdede41..60c502975a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -133,7 +133,7 @@ class AccountFilterAssembler( /** * The accounts whose always-on subscriptions are mounted right now, from * either mount path: a screen's [AccountFilterAssemblerSubscription] or the - * headless [BackgroundAccountSubscriptionRegistry]. + * headless [AccountSubscriptionRegistry]. * * This is the answer to "does this account currently pull from relays?", so * account-scoped loaders that live outside this assembler — the Cashu wallet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt similarity index 62% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt index dad23b3e85..96aabf0ef3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/BackgroundAccountSubscriptionRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountSubscriptionRegistry.kt @@ -25,51 +25,50 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log /** - * Mounts the always-on account loaders — notifications, DMs, gift wraps, drafts, - * metadata — for accounts that have no screen. + * Mounts the account-level loaders — notifications, DMs, gift wraps, drafts, + * metadata — for accounts that have no screen of their own. * * Every other mount of [AccountFilterAssembler] comes from a composable holding an * `AccountViewModel`, which means it only ever covers the account the user is - * looking at. Accounts the user asked to "keep active in the background" had no - * such mount: they were merely loaded into memory so pushed gift wraps could be - * decrypted by their owner, and everything else about them depended on a push - * message arriving. On a device with no push (no Play Services, no UnifiedPush - * distributor, Pokey not installed) those accounts pulled nothing at all. + * looking at. Every other logged-in account was merely resident in memory so pushed + * gift wraps could be decrypted by their owner; everything else about them depended + * on a push message arriving. On a device with no push (no Play Services, no + * UnifiedPush distributor, Pokey not installed) they pulled nothing at all. * - * This registry is the pull side of that promise. It holds one - * [AccountQueryState] per participating account and drives - * [AccountFilterAssembler.subscribe] / [AccountFilterAssembler.unsubscribe] - * directly — those are plain functions, not composables, so no UI has to exist. + * This registry is the pull side. It holds one [AccountQueryState] per account it is + * given and drives [AccountFilterAssembler.subscribe] / + * [AccountFilterAssembler.unsubscribe] directly — those are plain functions, not + * composables, so no UI has to exist. * * The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no - * `otherAccounts` — populating the account switcher's avatars is a screen's job, - * and these accounts have no screen. + * `otherAccounts` — populating the account switcher's avatars is a screen's job, and + * these accounts have no screen. * - * Ownership of the participating set lives in + * It deliberately decides nothing about *which* accounts those are: it mounts exactly + * the set it is handed, so the foreground/background rule lives in one place, in * [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager], - * which already watches the two flags that define it, and calls [sync] on every - * change. + * which already watches the switches that define it and calls [sync] on every change. */ -class BackgroundAccountSubscriptionRegistry( +class AccountSubscriptionRegistry( private val assembler: AccountFilterAssembler, ) { companion object { - private const val TAG = "BackgroundAccountSubs" + private const val TAG = "AccountSubscriptions" } private val mounted = mutableMapOf() /** - * Makes the mounted set match [participants] exactly: subscribes accounts that - * just opted in, unsubscribes accounts that opted out or were unloaded, and - * leaves untouched the ones already mounted. + * Makes the mounted set match [accounts] exactly: subscribes the ones that just + * joined it, unsubscribes the ones that left or were unloaded, and leaves the + * rest untouched. * - * Idempotent, so callers can hand it the same set on every emission of the - * flags flow without churning subscriptions. + * Idempotent, so callers can hand it the same set on every emission of the flows + * behind it without churning subscriptions. */ @Synchronized - fun sync(participants: Collection) { - val wanted = participants.associateBy { it.userProfile().pubkeyHex } + fun sync(accounts: Collection) { + val wanted = accounts.associateBy { it.userProfile().pubkeyHex } // Unmount accounts that dropped out, and accounts whose Account object was // replaced (re-login rebuilds it) — the stale instance holds the old @@ -91,11 +90,11 @@ class BackgroundAccountSubscriptionRegistry( } if (added > 0 || stale.isNotEmpty()) { - Log.d(TAG) { "Background account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } + Log.d(TAG) { "Account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" } } } - /** Unmounts everything. Used when the master switch goes off, and on logout. */ + /** Unmounts everything. Used when the app goes away with the master off, and on logout. */ @Synchronized fun clear() = sync(emptyList()) }