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.