fix: unwrap GiftWraps for all writable accounts when always-on is enabled

When the always-on notification service is on, preload every saved writable
account into AccountCacheState so each one's newNotesPreProcessor subscribes
to LocalCache.live.newEventBundles. Without this, relay-delivered GiftWraps
addressed to non-active accounts would sit in LocalCache undeconstructed,
and NotificationDispatcher never fires for them.

The preload observes LocalPreferences.accountsFlow so accounts added during
a session (login flow) join automatically. On disable, the collector is
cancelled and every cached account except the active one is released —
users with the setting off keep today's single-account battery footprint.

Loading an account is idempotent (AccountCacheState checks cache first),
so this cannot double-init or conflict with PushWrapDecryptor's on-demand
loadAccount calls. Loading does not affect the active account selected by
AccountSessionManager, so the UI is unaffected.
This commit is contained in:
Claude
2026-04-24 00:50:29 +00:00
parent 81819b6fbe
commit 6ad2135375
3 changed files with 108 additions and 2 deletions
@@ -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
@@ -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<HexKey>) {
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 =
@@ -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))
}
}
}