diff --git a/PULL_NOTIFICATION.md b/PULL_NOTIFICATION.md index d367c35ae..ef71d19e2 100644 --- a/PULL_NOTIFICATION.md +++ b/PULL_NOTIFICATION.md @@ -26,82 +26,61 @@ NIP-17 DM inbox relays ───────────WebSocket──┘ The service shares the **same `NostrClient` instance** as the UI. This is the key design decision. When the app is in the foreground, both the UI and the service are collecting -the `relayServices` flow and have active subscriptions. When the app backgrounds, UI -subscriptions drop but the service's subscriptions remain, keeping inbox and DM relay -connections alive. When the app returns to the foreground, the UI piggybacks on the -already-open connections. **Zero reconnection, zero dropped messages.** +the `relayServices` flow. The `AccountFilterAssembler` subscription from the Compose UI +tree stays active as long as the Activity exists (even when stopped/backgrounded), +keeping notification and DM relay connections alive. When the app returns to the +foreground, the UI piggybacks on the already-open connections. **Zero reconnection, zero +dropped messages.** ``` -BACKGROUND MODE: - inbox-relay-1 ──WebSocket──> [Service: svc:notif subscription] - inbox-relay-2 ──WebSocket──> [Service: svc:notif subscription] - dm-relay-1 ────WebSocket──> [Service: svc:giftwrap subscription] +BACKGROUND MODE (service running): + inbox-relay-1 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows] + inbox-relay-2 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows] + dm-relay-1 ────WebSocket──> [AccountFilterAssembler: gift wraps] + (outbox relays disconnected — no Home/Video/Discovery subscriptions) -APP OPENS: - inbox-relay-1 ──WebSocket──> [Same connection] <── UI adds feed subscriptions - inbox-relay-2 ──WebSocket──> [Same connection] <── UI adds feed subscriptions - dm-relay-1 ────WebSocket──> [Same connection] <── UI adds chat subscriptions - outbox-relay-3 ──WebSocket──> [New connection] <── feed-only relay +APP FOREGROUNDS: + inbox-relay-1 ──WebSocket──> [Same connection] <── Home/Discovery subs resume + inbox-relay-2 ──WebSocket──> [Same connection] <── Home/Discovery subs resume + dm-relay-1 ────WebSocket──> [Same connection] <── ChatroomList subs resume + outbox-relay-3 ──WebSocket──> [New connection] <── Home/Video feed relay ``` -## Relay Subscriptions +## Subscription Architecture -The service maintains two independent subscriptions on the shared `NostrClient`: +### What the service does -### `svc:notif` — Notification Inbox Relays +The service does NOT create its own relay subscriptions. It only keeps the +`RelayProxyClientConnector` alive by collecting `relayServices`. The actual subscriptions +come from the `AccountFilterAssembler` in the Compose tree (`LoggedInPage`), which +stays active as long as the Activity exists and covers: -Subscribes to the user's NIP-65 inbox relays (from `Account.notificationRelays.flow`) -for notification-relevant event kinds: +- **Metadata** — user profile, relay lists, mute lists, follows (via `AccountMetadataEoseManager`) +- **Follows** — follow list changes that affect notification filtering (via `AccountFollowsLoaderSubAssembler`) +- **Notifications** — mentions, zaps, reactions on NIP-65 inbox relays (via `AccountNotificationsEoseFromInboxRelaysManager`) +- **Gift wraps** — NIP-59 encrypted DMs on NIP-17 DM relays (via `AccountGiftWrapsEoseManager`) +- **Drafts** — draft events (via `AccountDraftsEoseManager`) -- **Summary kinds**: TextNote, Reaction, Repost, LnZap (limit 2000) -- **Per-key kinds**: Reports, Zaps, Channel Messages, Polls, Badges, etc. (limit 500) -- **Per-key kinds 2**: Git events, Highlights, Comments, Calendar events (limit 200) -- **Per-key kinds 3**: Attestation events (limit 10) +This is critical because notification filtering depends on follow lists, mute lists, +and relay configurations. If the service maintained its own isolated subscriptions, +it would miss follow list changes and display notifications from muted users. -All filtered by `p` tag matching the user's pubkey. +### What pauses on background -**Why separate from the UI's subscriptions?** The UI's `AccountNotificationsEoseFromInboxRelaysManager` -is part of `AccountFilterAssembler`, which is driven by the Compose lifecycle. When the -app backgrounds and composables leave composition, these subscriptions are dropped. The -relay pool then disconnects relays that no longer have any active subscriptions. The -service's `svc:notif` subscription ensures notification inbox relays stay connected. +Heavy feed subscriptions use `LifecycleAwareKeyDataSourceSubscription` which subscribes +on `ON_START` and unsubscribes on `ON_STOP`. When the app backgrounds: -### `svc:giftwrap` — DM Inbox Relays +| Subscription | Behavior | Why | +|-------------|----------|-----| +| `AccountFilterAssembler` | **Stays active** | Needed for notifications, DMs, follow/mute list changes | +| `HomeFilterAssembler` | **Pauses** | Home feed data with nobody viewing wastes bandwidth | +| `VideoFilterAssembler` | **Pauses** | Video feed data with nobody viewing wastes bandwidth | +| `DiscoveryFilterAssembler` | **Pauses** | Discovery feed data with nobody viewing wastes bandwidth | +| `ChatroomListFilterAssembler` | **Pauses** | Chatroom list updates with nobody viewing waste bandwidth | -Subscribes to the user's DM inbox relays (from `Account.dmRelays.flow`) for NIP-59 -gift-wrapped messages: - -- **Kinds**: GiftWrapEvent (1059), EphemeralGiftWrapEvent -- **Tag filter**: `p` tag matching the user's pubkey -- **Lookback**: 2 days from `since` timestamp - -The DM relay set is broader than the notification relay set — it combines NIP-17 DM relays -(ChatMessageRelayListEvent, kind 10050), NIP-65 inbox relays, private outbox relays, and -local relays. - -**Why?** DM relays may be completely different from notification inbox relays. Without -this subscription, DM relays that aren't also notification inbox relays would disconnect -when the app backgrounds, and incoming encrypted DMs would be missed. - -### Reactive Updates - -Both subscriptions reactively update when relay lists change: - -```kotlin -combine( - account.notificationRelays.flow, - account.dmRelays.flow, -) { notifRelays, dmRelays -> - Pair(notifRelays, dmRelays) -}.collectLatest { (notifRelays, dmRelays) -> - updateNotificationSubscription(account, notifRelays) - updateGiftWrapSubscription(account, dmRelays) -} -``` - -If the user adds or removes relays from their NIP-65 or NIP-17 lists, the service's -subscriptions are updated immediately. The shared relay pool handles connecting to new -relays and disconnecting from removed ones. +When the feed subscriptions pause, the relay pool automatically disconnects outbox relays +that no longer have any active subscriptions. Only inbox and DM relays stay connected +(because `AccountFilterAssembler` still has active subscriptions on them). ## Foreground Service @@ -284,7 +263,8 @@ The always-on notification service is **opt-in** (off by default). Users enable | File | Purpose | |------|---------| -| `NotificationRelayService.kt` | Foreground service, relay subscriptions, auto-restart | +| `NotificationRelayService.kt` | Foreground service, keeps relay connections alive, auto-restart | +| `LifecycleAwareKeyDataSourceSubscription.kt` | Pauses heavy feed subs when app backgrounds | | `BootCompletedReceiver.kt` | Restart on boot and app update | | `AutoRestartReceiver.kt` | Restart via WorkManager when service is destroyed | | `NotificationCatchUpWorker.kt` | Periodic and on-demand catch-up worker | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index da91a6502..28c964068 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -38,38 +38,30 @@ import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterNotificationsToPubkey -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.filterSummaryNotificationsToPubkey -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch /** * A foreground service that maintains persistent WebSocket connections to the user's * inbox relays for real-time notification delivery. * - * This service: - * - Keeps the shared NostrClient alive by collecting the relayServices flow - * - Routes incoming events through EventNotificationConsumer - * - Survives app backgrounding (connections stay open) - * - Uses specialUse foreground service type (no Android 15 time limit) + * This service keeps the shared NostrClient alive by collecting the relayServices flow. + * It does NOT create its own relay subscriptions — instead, it relies on the + * AccountFilterAssembler subscription from the Compose UI tree, which stays active + * as long as the Activity is alive (even when stopped/backgrounded). That subscription + * covers notifications, gift wraps, metadata, follows, relay lists, and drafts. * - * The key insight is that this service shares the same NostrClient as the UI. - * When the app is in the foreground, both the UI and this service are subscribers. - * When the app backgrounds, UI subscriptions drop but this service's subscriptions - * remain, keeping inbox relay connections alive. No reconnection needed. + * Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) are managed + * separately with lifecycle awareness — they pause when the app backgrounds + * and resume when it foregrounds. This prevents bandwidth waste on feeds + * nobody is viewing. * * Auto-restart mechanisms (inspired by ntfy): * - START_STICKY: Android restarts killed services @@ -127,9 +119,6 @@ class NotificationRelayService : Service() { private var connectedRelayCount = 0 private var foregroundStarted = false - private val subIdNotifications = "svc:notif" - private val subIdGiftWraps = "svc:giftwrap" - override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -198,16 +187,6 @@ class NotificationRelayService : Service() { override fun onDestroy() { Log.d(TAG, "Service destroyed") relayServiceCollectorJob?.cancel() - - // Clean up our subscriptions so the relay pool can disconnect - // relays that are no longer needed by anyone - try { - Amethyst.instance.client.unsubscribe(subIdNotifications) - Amethyst.instance.client.unsubscribe(subIdGiftWraps) - } catch (e: Exception) { - // App might already be torn down - } - scope.cancel() if (isEnabled(this)) { @@ -249,28 +228,32 @@ class NotificationRelayService : Service() { } } + /** + * Keeps the relay infrastructure alive. The service collects two flows: + * + * 1. relayServices: Keeps the RelayProxyClientConnector active (connectivity, + * Tor, network changes). Without this, the client disconnects 30s after + * the UI stops collecting. + * + * 2. connectedRelaysFlow: Updates the persistent notification with relay count. + * + * The service does NOT create its own relay subscriptions. Instead, it relies on + * the AccountFilterAssembler subscription that lives in the Compose tree (LoggedInPage). + * That subscription stays active as long as the Activity exists (even when stopped) + * and covers: metadata, follows, notifications (inbox relays), gift wraps (DM relays), + * drafts, and relay list changes. Since the service keeps the client connected, + * those subscriptions remain active on the relays. + */ private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = scope.launch { - // Collecting the relayServices flow keeps the RelayProxyClientConnector alive. - // This is the same flow that ManageRelayServices() composable collects in the UI. - // By collecting it here, relay connections survive app backgrounding. launch { Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest { Log.d(TAG) { "Relay services state updated: $it" } } } - // Maintain notification + DM subscriptions on inbox relays. - // When the UI backgrounds, its subscriptions drop and relays that are - // only needed for those subscriptions disconnect. By opening our own - // subscriptions here, we ensure inbox + DM relay connections persist. - launch { - maintainInboxSubscriptions() - } - - // Monitor connected relay count to update the notification launch { Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> val count = relays.size @@ -283,99 +266,6 @@ class NotificationRelayService : Service() { } } - /** - * Watches the logged-in account's notification inbox relays and DM inbox relays, - * maintaining lightweight subscriptions on them. This ensures these relay connections - * persist even when the UI's subscriptions drop. - * - * Notification relays: NIP-65 inbox + local relays → subscribe to mentions, zaps, etc. - * DM relays: NIP-17 DM relays + NIP-65 inbox + private outbox + local relays → gift wraps - */ - private suspend fun maintainInboxSubscriptions() { - // Wait for account to be available - Amethyst.instance.sessionManager.accountContent.collectLatest { state -> - val account = - (state as? com.vitorpamplona.amethyst.ui.screen.AccountState.LoggedIn)?.account - - if (account == null || !account.isWriteable()) { - // No account or read-only — close any existing subscriptions - Amethyst.instance.client.unsubscribe(subIdNotifications) - Amethyst.instance.client.unsubscribe(subIdGiftWraps) - return@collectLatest - } - - // Reactively update subscriptions when relay lists change - combine( - account.notificationRelays.flow, - account.dmRelays.flow, - ) { notifRelays, dmRelays -> - Pair(notifRelays, dmRelays) - }.collectLatest { (notifRelays, dmRelays) -> - updateNotificationSubscription(account, notifRelays) - updateGiftWrapSubscription(account, dmRelays) - } - } - } - - private fun updateNotificationSubscription( - account: Account, - relays: Set, - ) { - if (relays.isEmpty()) { - Amethyst.instance.client.unsubscribe(subIdNotifications) - return - } - - val pubkey = account.signer.pubKey - val since = TimeUtils.oneWeekAgo() - val filters = mutableMapOf>() - - relays.forEach { relay -> - val relayFilters = - ( - filterSummaryNotificationsToPubkey(relay, pubkey, since) + - filterNotificationsToPubkey(relay, pubkey, since) - ).map { it.filter } - - if (relayFilters.isNotEmpty()) { - filters[relay] = relayFilters - } - } - - if (filters.isNotEmpty()) { - Amethyst.instance.client.subscribe(subIdNotifications, filters) - Log.d(TAG) { "Subscribed to notifications on ${filters.size} relays" } - } - } - - private fun updateGiftWrapSubscription( - account: Account, - relays: Set, - ) { - if (relays.isEmpty()) { - Amethyst.instance.client.unsubscribe(subIdGiftWraps) - return - } - - val pubkey = account.signer.pubKey - val since = TimeUtils.oneWeekAgo() - val filters = mutableMapOf>() - - relays.forEach { relay -> - val relayFilters = - filterGiftWrapsToPubkey(relay, pubkey, since).map { it.filter } - - if (relayFilters.isNotEmpty()) { - filters[relay] = relayFilters - } - } - - if (filters.isNotEmpty()) { - Amethyst.instance.client.subscribe(subIdGiftWraps, filters) - Log.d(TAG) { "Subscribed to gift wraps on ${filters.size} DM relays" } - } - } - private fun updateNotification(connectedRelays: Int) { val notification = buildNotification(connectedRelays) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index 4fbf4176e..dc778c521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -45,5 +45,5 @@ fun ChatroomListFilterAssemblerSubscription( ChatroomListState(accountViewModel.account) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index f0cd98b46..54a518e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -46,5 +46,5 @@ fun DiscoveryFilterAssemblerSubscription( DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index 13ced4050..bdec0fcb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -50,5 +50,5 @@ fun HomeFilterAssemblerSubscription( ) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index ab49de65f..dabf9ec72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -46,5 +46,5 @@ fun VideoFilterAssemblerSubscription( VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } - KeyDataSourceSubscription(state, filterAssembler) + LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt new file mode 100644 index 000000000..bbd9bc1c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager + +/** + * A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes + * when the lifecycle reaches STARTED and unsubscribes when it reaches STOPPED. + * + * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) + * that should NOT run when the app is in the background. When an always-on + * notification service keeps the relay client connected, these subscriptions + * would otherwise leak bandwidth on feeds nobody is viewing. + * + * Lightweight subscriptions that should always run (account metadata, notifications, + * gift wraps) should continue using the regular [KeyDataSourceSubscription]. + */ +@Composable +fun LifecycleAwareKeyDataSourceSubscription( + state: T, + dataSource: ComposeSubscriptionManager, +) { + val lifecycleOwner = LocalLifecycleOwner.current + var isStarted by remember { mutableStateOf(false) } + + DisposableEffect(state, lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (!isStarted) { + dataSource.subscribe(state) + isStarted = true + } + } + + Lifecycle.Event.ON_STOP -> { + if (isStarted) { + dataSource.unsubscribe(state) + isStarted = false + } + } + + else -> {} + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + // If already started (e.g., recomposition while visible), subscribe immediately + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + dataSource.subscribe(state) + isStarted = true + } + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (isStarted) { + dataSource.unsubscribe(state) + isStarted = false + } + } + } +}