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()) }