Merge pull request #3266 from vitorpamplona/claude/relay-connections-background-03x58e

Fix lifecycle-aware subscriptions and notification relay throttling
This commit is contained in:
Vitor Pamplona
2026-06-18 15:42:28 -04:00
committed by GitHub
13 changed files with 107 additions and 49 deletions
@@ -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 =
@@ -1341,7 +1341,9 @@
<string name="call_settings_turn_credential">Pověření</string>
<string name="always_on_notif_channel_description">Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase</string>
<string name="always_on_notif_title">Amethyst oznámení aktivní</string>
<string name="always_on_notif_connected">Připojeno k %1$d inbox relayím</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Připojeno k %1$d inbox relayím</item>
</plurals>
<string name="always_on_notif_connecting">Připojování k inbox relayím\u2026</string>
<string name="always_on_notif_setting_title">Služba trvalých oznámení</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1325,7 +1325,9 @@ anz der Bedingungen ist erforderlich</string>
<string name="call_settings_turn_credential">Anmeldeinformationen</string>
<string name="always_on_notif_channel_description">Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen</string>
<string name="always_on_notif_title">Amethyst-Benachrichtigungen aktiv</string>
<string name="always_on_notif_connected">Mit %1$d Inbox-Relays verbunden</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Mit %1$d Inbox-Relays verbunden</item>
</plurals>
<string name="always_on_notif_connecting">Verbinde mit Inbox-Relays\u2026</string>
<string name="always_on_notif_setting_title">Dauerhafter Benachrichtigungsdienst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1311,7 +1311,9 @@
<string name="call_settings_turn_credential">योग्यता प्रमाण</string>
<string name="always_on_notif_channel_description">आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए</string>
<string name="always_on_notif_title">अमेथिस्ट सूचनाएँ सक्रिय</string>
<string name="always_on_notif_connected">संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ</string>
<plurals name="always_on_notif_connected">
<item quantity="other">संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ</item>
</plurals>
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
@@ -1327,7 +1327,9 @@
<string name="call_settings_turn_credential">Hitelesítési adatok</string>
<string name="always_on_notif_channel_description">Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében</string>
<string name="always_on_notif_title">Amethyst értesítések aktíválva</string>
<string name="always_on_notif_connected">Kapcsolódva %1$d beérkező üzenetátjátszóhoz</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Kapcsolódva %1$d beérkező üzenetátjátszóhoz</item>
</plurals>
<string name="always_on_notif_connecting">Kapcsolódás a beérkező üzenetátjátszókhoz\u2026</string>
<string name="always_on_notif_setting_title">Folyamatos értesítési szolgáltatás</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1278,7 +1278,9 @@
<string name="call_settings_turn_credential">Credential</string>
<string name="always_on_notif_channel_description">Houdt verbindingen met je inbox-relays actief voor realtime meldingen</string>
<string name="always_on_notif_title">Amethyst-meldingen actief</string>
<string name="always_on_notif_connected">Verbonden met %1$d inbox-relays</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Verbonden met %1$d inbox-relays</item>
</plurals>
<string name="always_on_notif_connecting">Verbinden met inbox-relays…</string>
<string name="always_on_notif_setting_title">Altijd-aan meldingsdienst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1345,7 +1345,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="call_settings_turn_credential">Uwierzytelnienie</string>
<string name="always_on_notif_channel_description">Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym</string>
<string name="always_on_notif_title">Powiadomienia Ametyst Aktywne</string>
<string name="always_on_notif_connected">Połączono z %1$d transmiterami odbiorczymi</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Połączono z %1$d transmiterami odbiorczymi</item>
</plurals>
<string name="always_on_notif_connecting">Łączenie z transmiterami odbiorczymi\u2026</string>
<string name="always_on_notif_setting_title">Usługa powiadomień zawsze włączona</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1320,7 +1320,9 @@
<string name="call_settings_turn_credential">Credencial</string>
<string name="always_on_notif_channel_description">Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real</string>
<string name="always_on_notif_title">Notificações do Amethyst ativas</string>
<string name="always_on_notif_connected">Conectado a %1$d relays de caixa de entrada</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Conectado a %1$d relays de caixa de entrada</item>
</plurals>
<string name="always_on_notif_connecting">Conectando aos relays de caixa de entrada\u2026</string>
<string name="always_on_notif_setting_title">Serviço de notificações sempre ativo</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1359,7 +1359,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="call_settings_turn_credential">Akreditivi</string>
<string name="always_on_notif_channel_description">Ohranja aktivne povezave z vašimi releji za obvestila v realnem času</string>
<string name="always_on_notif_title">Amethyst obvestila so aktivna</string>
<string name="always_on_notif_connected">Povezan z %1$d vhodnimi releji</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Povezan z %1$d vhodnimi releji</item>
</plurals>
<string name="always_on_notif_connecting">Povezovanje vhodnih relejev\u2026</string>
<string name="always_on_notif_setting_title">Vedno aktivna obvestila</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1320,7 +1320,9 @@
<string name="call_settings_turn_credential">Inloggningsuppgift</string>
<string name="always_on_notif_channel_description">Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar</string>
<string name="always_on_notif_title">Amethyst-notifieringar aktiva</string>
<string name="always_on_notif_connected">Ansluten till %1$d inbox-relän</string>
<plurals name="always_on_notif_connected">
<item quantity="other">Ansluten till %1$d inbox-relän</item>
</plurals>
<string name="always_on_notif_connecting">Ansluter till inbox-relän\u2026</string>
<string name="always_on_notif_setting_title">Alltid på-notifieringstjänst</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -1318,7 +1318,9 @@
<string name="call_settings_turn_credential">凭证</string>
<string name="always_on_notif_channel_description">保持与收件箱中继的连接以便接收实时通知</string>
<string name="always_on_notif_title">Amethyst 通知活跃</string>
<string name="always_on_notif_connected">已连接到 %1$d 个收件箱中继</string>
<plurals name="always_on_notif_connected">
<item quantity="other">已连接到 %1$d 个收件箱中继</item>
</plurals>
<string name="always_on_notif_connecting">正在连接到收件箱中继\u2026</string>
<string name="always_on_notif_setting_title">“始终显示通知”服务</string>
<string name="always_on_notif_setting_description">保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。</string>
+8 -1
View File
@@ -1438,7 +1438,14 @@
<string name="always_on_notif_channel_name" translatable="false">Relay Connection Service</string>
<string name="always_on_notif_channel_description">Keeps connections to your inbox relays active for real-time notifications</string>
<string name="always_on_notif_title">Amethyst Notifications Active</string>
<string name="always_on_notif_connected">Connected to %1$d inbox relays</string>
<plurals name="always_on_notif_connected">
<item quantity="one">Connected to %1$d inbox relay</item>
<item quantity="other">Connected to %1$d inbox relays</item>
</plurals>
<plurals name="always_on_notif_connected_foreground">
<item quantity="one">Connected to %1$d relay</item>
<item quantity="other">Connected to %1$d relays</item>
</plurals>
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>
@@ -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.