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:
@@ -395,8 +395,17 @@ class AppModules(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manages always-on notification service lifecycle
|
// Manages always-on notification service lifecycle. Preloads every saved
|
||||||
val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope)
|
// 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
|
// Observes LocalCache for notification-relevant events and routes them to
|
||||||
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
|
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
|
||||||
|
|||||||
+37
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.model.accountsCache
|
package com.vitorpamplona.amethyst.model.accountsCache
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
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 =
|
fun loadAccount(accountSettings: AccountSettings): Account =
|
||||||
loadAccount(
|
loadAccount(
|
||||||
signer =
|
signer =
|
||||||
|
|||||||
+60
@@ -21,8 +21,12 @@
|
|||||||
package com.vitorpamplona.amethyst.service.notifications
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
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 com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
@@ -40,16 +44,27 @@ import kotlinx.coroutines.launch
|
|||||||
* When enabled, all layers activate. When disabled, all layers deactivate.
|
* When enabled, all layers activate. When disabled, all layers deactivate.
|
||||||
* The manager watches the account's alwaysOnNotificationService setting
|
* The manager watches the account's alwaysOnNotificationService setting
|
||||||
* and reacts to changes in real time.
|
* 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(
|
class AlwaysOnNotificationServiceManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val accountsCache: AccountCacheState,
|
||||||
|
private val localPreferences: LocalPreferences,
|
||||||
|
private val activePubKeyProvider: () -> HexKey?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AlwaysOnNotifManager"
|
private const val TAG = "AlwaysOnNotifManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var watchJob: Job? = null
|
private var watchJob: Job? = null
|
||||||
|
private var preloadJob: Job? = null
|
||||||
private var wasEnabled = false
|
private var wasEnabled = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +91,8 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
watchJob?.cancel()
|
watchJob?.cancel()
|
||||||
watchJob = null
|
watchJob = null
|
||||||
|
preloadJob?.cancel()
|
||||||
|
preloadJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableAllLayers() {
|
private fun enableAllLayers() {
|
||||||
@@ -91,6 +108,8 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
ServiceWatchdogManager.schedule(context)
|
ServiceWatchdogManager.schedule(context)
|
||||||
|
|
||||||
// L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest
|
// L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest
|
||||||
|
|
||||||
|
startMultiAccountPreload()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableAllLayers() {
|
private fun disableAllLayers() {
|
||||||
@@ -104,5 +123,46 @@ class AlwaysOnNotificationServiceManager(
|
|||||||
|
|
||||||
// L5: Cancel watchdog alarm
|
// L5: Cancel watchdog alarm
|
||||||
ServiceWatchdogManager.cancel(context)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user