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 index 6a5df9fa91..ce86ad760c 100644 --- 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 @@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState -import kotlinx.coroutines.Job +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L @@ -47,6 +49,17 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L * rebuilding the relay REQ — which would otherwise lose EOSE state and * trigger a refetch on return. * + * The grace timer runs on a dedicated [Dispatchers.Default] scope driven by + * [Lifecycle.currentStateFlow] rather than on the composition's frame-clock + * coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI + * frame clock stops ticking, so a timer scheduled there could be starved and + * the unsubscribe — and therefore the relay disconnect it triggers — might + * never run. This is most visible on the relay feed, whose dedicated one-off + * relay is kept connected by nothing else and would leak forever. Using a + * plain coroutine dispatcher keeps the timer firing while backgrounded; + * [collectLatest] cancels the pending delay automatically the moment the + * lifecycle returns to STARTED. + * * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) * that should NOT run when the app is truly in the background. When an * always-on notification service keeps the relay client connected, these @@ -60,66 +73,11 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: ComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null - - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } - - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { - delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} - } - } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() - } - - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - } - } + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) } @Composable @@ -127,64 +85,51 @@ fun LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: MutableComposeSubscriptionManager, ) { - val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() + LifecycleAwareSubscription( + key = state, + subscribe = { dataSource.subscribe(state) }, + unsubscribe = { dataSource.unsubscribe(state) }, + ) +} - DisposableEffect(state, lifecycleOwner) { - var isSubscribed = false - var pendingUnsubscribe: Job? = null +@Composable +private fun LifecycleAwareSubscription( + key: Any?, + subscribe: () -> Unit, + unsubscribe: () -> Unit, +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle - fun subscribeNow() { - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (!isSubscribed) { - dataSource.subscribe(state) - isSubscribed = true - } - } + DisposableEffect(key, lifecycle) { + // Background scope so the grace timer is not gated by the UI frame clock, + // which stops ticking while the app is backgrounded. + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - fun scheduleUnsubscribe() { - if (!isSubscribed || pendingUnsubscribe != null) return - pendingUnsubscribe = - scope.launch { + scope.launch { + // `subscribed` is confined to this single collector coroutine, so no + // cross-thread synchronization is needed for it. + var subscribed = false + lifecycle.currentStateFlow.collectLatest { current -> + if (current.isAtLeast(Lifecycle.State.STARTED)) { + if (!subscribed) { + subscribe() + subscribed = true + } + } else if (subscribed) { + // Stopped: keep the REQ alive for a short grace period. + // collectLatest cancels this delay if we return to STARTED first. delay(UNSUBSCRIBE_GRACE_MILLIS) - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } - pendingUnsubscribe = null - } - } - - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - subscribeNow() - } - - Lifecycle.Event.ON_STOP -> { - scheduleUnsubscribe() - } - - else -> {} + unsubscribe() + subscribed = false } } - - lifecycleOwner.lifecycle.addObserver(observer) - - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - subscribeNow() } onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - pendingUnsubscribe?.cancel() - pendingUnsubscribe = null - if (isSubscribed) { - dataSource.unsubscribe(state) - isSubscribed = false - } + scope.cancel() + // Idempotent: removing an absent key is a cheap no-op. Guarantees the + // subscription is released even if the grace timer was still pending. + unsubscribe() } } }