diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 06f0066bc..9cd398148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -395,8 +395,17 @@ class AppModules( ) } - // Manages always-on notification service lifecycle - val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope) + // Manages always-on notification service lifecycle. Preloads every saved + // writable account while enabled so GiftWraps for non-active accounts still + // get unwrapped by their owning account's newNotesPreProcessor. + val alwaysOnNotificationServiceManager = + AlwaysOnNotificationServiceManager( + context = appContext, + scope = applicationIOScope, + accountsCache = accountsCache, + localPreferences = LocalPreferences, + activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey }, + ) // Observes LocalCache for notification-relevant events and routes them to // EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index cbf023d8c..bec0e724d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache @@ -68,6 +69,42 @@ class AccountCacheState( } } + /** + * Loads every saved account that can sign (has a private key or an external signer) + * into the cache. Safe to call repeatedly — [loadAccount] is idempotent, so already + * loaded accounts are returned as-is. Used by the always-on notification service so + * GiftWraps addressed to non-active accounts still get unwrapped and notified. + */ + suspend fun loadAllWritableAccounts(localPreferences: LocalPreferences) { + localPreferences.allSavedAccounts().forEach { savedAccount -> + if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach + try { + val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach + loadAccount(accountSettings) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w("AccountCacheState", "Failed to preload account ${savedAccount.npub}: ${e.message}", e) + } + } + } + + /** + * Cancels and removes every cached account whose pubkey is not in [keepPubkeys]. + * Used to release accounts that were preloaded for background notification handling + * when the always-on service is turned off, while preserving the active account. + */ + fun retainOnly(keepPubkeys: Set) { + accounts.update { existingAccounts -> + val toRemove = existingAccounts.filterKeys { it !in keepPubkeys } + if (toRemove.isEmpty()) { + existingAccounts + } else { + toRemove.values.forEach { it.scope.cancel() } + existingAccounts.minus(toRemove.keys) + } + } + } + fun loadAccount(accountSettings: AccountSettings): Account = loadAccount( signer = 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 37fc34447..8cffc6edb 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 @@ -21,8 +21,12 @@ 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.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -40,16 +44,27 @@ import kotlinx.coroutines.launch * When enabled, all layers activate. When disabled, all layers deactivate. * The manager watches the account's alwaysOnNotificationService setting * and reacts to changes in real time. + * + * While enabled, every saved writable account is kept loaded in + * [AccountCacheState] so 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. */ class AlwaysOnNotificationServiceManager( private val context: Context, private val scope: CoroutineScope, + private val accountsCache: AccountCacheState, + private val localPreferences: LocalPreferences, + private val activePubKeyProvider: () -> HexKey?, ) { companion object { private const val TAG = "AlwaysOnNotifManager" } private var watchJob: Job? = null + private var preloadJob: Job? = null private var wasEnabled = false /** @@ -76,6 +91,8 @@ class AlwaysOnNotificationServiceManager( fun stop() { watchJob?.cancel() watchJob = null + preloadJob?.cancel() + preloadJob = null } private fun enableAllLayers() { @@ -91,6 +108,8 @@ class AlwaysOnNotificationServiceManager( ServiceWatchdogManager.schedule(context) // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest + + startMultiAccountPreload() } private fun disableAllLayers() { @@ -104,5 +123,46 @@ class AlwaysOnNotificationServiceManager( // L5: Cancel watchdog alarm ServiceWatchdogManager.cancel(context) + + stopMultiAccountPreload() + } + + /** + * Preloads every saved writable account into [AccountCacheState] and keeps the set + * in sync by observing [LocalPreferences.accountsFlow]. New accounts added while + * the service is enabled (login flow) are picked up automatically. + * + * Note: the first [LocalPreferences.accountsFlow] emission is `null` (lazily + * populated). We still call [AccountCacheState.loadAllWritableAccounts] on every + * emission — its suspend call to `allSavedAccounts()` triggers flow population, + * and subsequent [loadAccount] calls are idempotent on already-loaded accounts. + */ + private fun startMultiAccountPreload() { + preloadJob?.cancel() + preloadJob = + scope.launch { + localPreferences.accountsFlow().collect { + try { + accountsCache.loadAllWritableAccounts(localPreferences) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Multi-account preload failed: ${e.message}", e) + } + } + } + } + + /** + * Cancels the preload collector and releases every cached account except the + * currently active one, so users with the setting off return to single-account + * memory/battery footprint. + */ + private fun stopMultiAccountPreload() { + preloadJob?.cancel() + preloadJob = null + val active = activePubKeyProvider() + if (active != null) { + accountsCache.retainOnly(setOf(active)) + } } }