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 93a556a01a..36acb126e2 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 @@ -39,13 +39,16 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch /** @@ -79,6 +82,10 @@ class NotificationRelayService : Service() { private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + // Throttle interval for refreshing the persistent notification's relay count. + // Keeps notification updates well under Android's rate limit (~10/s). + private const val NOTIFICATION_REFRESH_MS = 1000L + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" fun start(context: Context) { @@ -242,6 +249,7 @@ class NotificationRelayService : Service() { * drafts, and relay list changes. Since the service keeps the client connected, * those subscriptions remain active on the relays. */ + @OptIn(FlowPreview::class) private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = @@ -253,13 +261,22 @@ class NotificationRelayService : Service() { } launch { - Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> - val count = relays.size - if (count != connectedRelayCount) { - connectedRelayCount = count - updateNotification(count) + // sample() caps how often we touch the notification. During feed + // load/teardown connectedRelaysFlow churns dozens of times per second; + // posting on every delta blows past Android's notification rate limit + // (~10/s), which silently drops updates and leaves the visible count + // stuck on a stale intermediate value. One refresh per second stays + // well under the limit and always lands the settled count. + Amethyst.instance.client + .connectedRelaysFlow() + .sample(NOTIFICATION_REFRESH_MS) + .collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } } - } } } } @@ -272,10 +289,15 @@ class NotificationRelayService : Service() { private fun buildNotification(connectedRelays: Int): Notification { val contentText = - if (connectedRelays > 0) { - getString(R.string.always_on_notif_connected, connectedRelays) - } else { - getString(R.string.always_on_notif_connecting) + when { + connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting) + // Foreground: the pool also holds the feed/finder outbox relays, so the + // count reflects all connections, not just the inbox. Backgrounded, the + // feeds tear down and only inbox + DM relays remain. + MainActivity.isResumed -> + pluralStringRes(this, R.plurals.always_on_notif_connected_foreground, connectedRelays, connectedRelays) + else -> + pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } val openAppIntent = diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3d2e2564e7..76896477df 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1341,7 +1341,9 @@ Pověření Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase Amethyst oznámení aktivní - Připojeno k %1$d inbox relayím + + Připojeno k %1$d inbox relayím + Připojování k inbox relayím\u2026 Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 688d63940a..7862fd3944 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1325,7 +1325,9 @@ anz der Bedingungen ist erforderlich Anmeldeinformationen Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen Amethyst-Benachrichtigungen aktiv - Mit %1$d Inbox-Relays verbunden + + Mit %1$d Inbox-Relays verbunden + Verbinde mit Inbox-Relays\u2026 Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 2d3a1259c1..b823bdc436 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1311,7 +1311,9 @@ योग्यता प्रमाण आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए अमेथिस्ट सूचनाएँ सक्रिय - संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ + + संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ + आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 08b1b94ec7..bc307b7d33 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1327,7 +1327,9 @@ Hitelesítési adatok Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében Amethyst értesítések aktíválva - Kapcsolódva %1$d beérkező üzenetátjátszóhoz + + Kapcsolódva %1$d beérkező üzenetátjátszóhoz + Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index ba4e98ab9d..cb3a3596b6 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1278,7 +1278,9 @@ Credential Houdt verbindingen met je inbox-relays actief voor realtime meldingen Amethyst-meldingen actief - Verbonden met %1$d inbox-relays + + Verbonden met %1$d inbox-relays + Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b8fc236ea5..0773c2e692 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1345,7 +1345,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Uwierzytelnienie Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym Powiadomienia Ametyst Aktywne - Połączono z %1$d transmiterami odbiorczymi + + Połączono z %1$d transmiterami odbiorczymi + Łączenie z transmiterami odbiorczymi\u2026 Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7cb94e3c62..f175be9395 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1320,7 +1320,9 @@ Credencial Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real Notificações do Amethyst ativas - Conectado a %1$d relays de caixa de entrada + + Conectado a %1$d relays de caixa de entrada + Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 103e831293..5d8ce9a8c8 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1359,7 +1359,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Akreditivi Ohranja aktivne povezave z vašimi releji za obvestila v realnem času Amethyst obvestila so aktivna - Povezan z %1$d vhodnimi releji + + Povezan z %1$d vhodnimi releji + Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index fa489d038c..68915dfb34 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1320,7 +1320,9 @@ Inloggningsuppgift Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar Amethyst-notifieringar aktiva - Ansluten till %1$d inbox-relän + + Ansluten till %1$d inbox-relän + Ansluter till inbox-relän\u2026 Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 8b53b43af4..8f85197c0c 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1318,7 +1318,9 @@ 凭证 保持与收件箱中继的连接以便接收实时通知 Amethyst 通知活跃 - 已连接到 %1$d 个收件箱中继 + + 已连接到 %1$d 个收件箱中继 + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c1ee7fc3f..07da4a218b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1438,7 +1438,14 @@ Relay Connection Service Keeps connections to your inbox relays active for real-time notifications Amethyst Notifications Active - Connected to %1$d inbox relays + + Connected to %1$d inbox relay + Connected to %1$d inbox relays + + + Connected to %1$d relay + Connected to %1$d relays + Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. 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 7b8f7334ea..cbddea997f 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 @@ -23,16 +23,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job 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 @@ -49,16 +50,14 @@ 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. + * Lifecycle transitions are observed with a main-thread [LifecycleEventObserver], + * which fires synchronously during `onStop`/`onStart`. Detecting the transition + * via a background-dispatched flow instead delivered `ON_STOP` up to ~60s late on + * a backgrounded device (the collector only resumed on the next relay keep-alive + * tick), leaving feeds connected long after the app was paused. Only the grace + * *delay* runs on a [Dispatchers.Default] scope, so it isn't gated by the UI + * frame clock (which stops ticking while backgrounded); returning to STARTED + * cancels the pending unsubscribe before it fires. * * 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 @@ -113,31 +112,41 @@ private fun LifecycleAwareSubscription( val lifecycle = LocalLifecycleOwner.current.lifecycle 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. + // Only the grace delay runs on a background scope so it isn't gated by the UI + // frame clock, which stops ticking while the app is backgrounded. val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - 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) { + // graceJob is only ever read/written from the main thread (observer callbacks), + // so no synchronization is needed. subscribe()/unsubscribe() are idempotent + // (reference-counted map ops), so re-issuing subscribe() on each ON_START is safe. + var graceJob: Job? = null + + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + graceJob?.cancel() + graceJob = null 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) - unsubscribe() - subscribed = false + + Lifecycle.Event.ON_STOP -> { + graceJob?.cancel() + graceJob = + scope.launch { + if (UNSUBSCRIBE_GRACE_MILLIS > 0) delay(UNSUBSCRIBE_GRACE_MILLIS) + unsubscribe() + } + } + + else -> {} } } - } + + lifecycle.addObserver(observer) onDispose { + lifecycle.removeObserver(observer) 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.