perf: reduce background and mobile-data battery usage

Five targeted fixes for the biggest idle drains:

- NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for
  the whole life of the process even after disconnect() (i.e. the entire
  time the app sat in the background). isActive is now a StateFlow and
  the loop suspends on it, so an inactive client schedules zero timers.

- Relay WebSocket pings on cellular: every ping on an idle connection
  promotes the radio out of its low-power state, per connection. The
  mobile-data client now pings every 240s instead of 120s (wifi keeps
  120s), staying under common carrier NAT idle timeouts.

- Always-on service watchdog: the 5-min health-check alarm no longer
  uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a
  service that can't do useful network work on a sleeping device was
  pure cost; the alarm now fires as soon as the device is next awake,
  and the WorkManager + FCM/UnifiedPush layers still cover doze.

- ScheduledPostWorker: the 15-min periodic worker was enqueued forever
  for every user, waking (often cold-starting) the process with nothing
  to publish. It is now enqueued exactly while the store holds a PENDING
  post, driven by a store-flow observer in AppModules.

- CalendarReminderWorker: same unconditional 15-min periodic schedule,
  but worse — LocalCache is memory-only, so a WorkManager wake of a dead
  process can never find an RSVP to remind about. The worker is now
  scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its
  own chain when nothing upcoming remains (or the feature is disabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
This commit is contained in:
Claude
2026-07-11 21:29:33 +00:00
parent 0053edf2dd
commit 3feb0049e6
8 changed files with 163 additions and 37 deletions
+7 -3
View File
@@ -158,13 +158,17 @@ Google Play Services infrastructure.
### Layer 4: AlarmManager Watchdog (5 minutes) ### Layer 4: AlarmManager Watchdog (5 minutes)
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5 **What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5
minutes. The receiver checks if the service should be running and restarts it. minutes. The receiver checks if the service should be running and restarts it.
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail **Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't (sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to delivered), the watchdog will catch it within 5 minutes of the device being awake.
wake the device from sleep, ensuring the check happens even in Doze. The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of
sleep every 5 minutes is a battery cost with no payoff, because a service restarted
on a sleeping device can't do useful network work until the device wakes anyway.
While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover
delivery; the moment the device wakes, the pending watchdog alarm fires.
### Layer 5: WorkManager Periodic Catch-Up (15 minutes) ### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
@@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
@@ -105,6 +107,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
@@ -124,6 +127,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
@@ -887,17 +892,48 @@ class AppModules(
// starts observing LocalCache for notification-worthy events // starts observing LocalCache for notification-worthy events
notificationDispatcher.start() notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up). // Keep the scheduled-posts worker (15-min periodic + one-time catch-up)
// Runs independently of the always-on notification setting so scheduled // enqueued exactly while the store holds a PENDING post. An always-on
// posts still fire when always-on notifications are disabled. // periodic worker wakes — and often cold-starts — the whole process every
ScheduledPostWorker.schedule(appContext) // 15 minutes forever, even for users who never schedule a post. The store
ScheduledPostWorker.scheduleCatchUp(appContext) // is durable (JSON on disk) and the single source of truth, so the worker
// is (re-)enqueued from any mutation that produces a PENDING row and
// cancelled when the last one drains. Runs independently of the always-on
// notification setting so scheduled posts still fire when always-on
// notifications are disabled.
applicationIOScope.launch {
// Force the initial disk load; the flow's initial value is an empty
// list until the store is first touched.
scheduledPostStore.list()
scheduledPostStore.flow
.map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } }
.distinctUntilChanged()
.collect { hasPending ->
if (hasPending) {
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
} else {
ScheduledPostWorker.cancelPeriodic(appContext)
}
}
}
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the // "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager // ACCEPTED. The 15-min periodic scanner is only scheduled while it can
// periodic minimum and the lead-time window. // plausibly fire: this observer enqueues it when an accepted RSVP lands in
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker // LocalCache, and the worker cancels its own chain when the cache holds
.schedule(appContext) // nothing that could still start. LocalCache is memory-only, so the
// unconditional schedule this replaces could never fire from a WorkManager
// cold start anyway — it only cost battery.
applicationIOScope.launch {
LocalCache
.observeNewEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND)))
.collect { rsvp ->
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) {
CalendarReminderWorker.schedule(appContext)
}
}
}
// Watch for account login and start/stop always-on notification service // Watch for account login and start/stop always-on notification service
applicationIOScope.launch { applicationIOScope.launch {
@@ -47,6 +47,13 @@ import java.util.concurrent.TimeUnit
* consults [CalendarReminderStore] to skip events that have already been notified for. Run as * consults [CalendarReminderStore] to skip events that have already been notified for. Run as
* a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of * a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of
* the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon"). * the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon").
*
* The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP
* observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and
* [doWork] cancels the chain when the cache holds no accepted RSVP that could still start.
* LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty
* cache and can never fire a reminder — an unconditional periodic schedule would cold-start
* the whole app graph every 15 minutes forever for zero benefit.
*/ */
class CalendarReminderWorker( class CalendarReminderWorker(
appContext: Context, appContext: Context,
@@ -55,7 +62,10 @@ class CalendarReminderWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext) val prefs = CalendarReminderPrefs(applicationContext)
if (!prefs.isEnabled()) { if (!prefs.isEnabled()) {
Log.d(TAG) { "Reminders disabled; skipping scan." } Log.d(TAG) { "Reminders disabled; ending periodic chain." }
// The settings toggle re-schedules on enable; no reason to keep
// waking the process while the feature is off.
cancel(applicationContext)
return Result.success() return Result.success()
} }
val now = TimeUtils.now() val now = TimeUtils.now()
@@ -110,6 +120,27 @@ class CalendarReminderWorker(
// Prune entries for events that ended more than a day ago — they can't fire again. // Prune entries for events that ended more than a day ago — they can't fire again.
store.forgetBefore(now - PRUNE_AGE_SECONDS) store.forgetBefore(now - PRUNE_AGE_SECONDS)
// Nothing left that could ever fire → end the periodic chain instead of
// waking the process every 15 minutes forever. An RSVP whose target event
// hasn't been fetched yet (start unknown) counts as "could still fire" so
// the chain survives until the target resolves. The ACCEPTED-RSVP observer
// in AppModules re-schedules the worker the next time a live session sees
// an accepted RSVP.
val couldStillFire =
acceptedRsvps.any { rsvp ->
val targetAddress = rsvp.calendarEventAddress() ?: return@any false
val start =
LocalCache.addressables
.get(targetAddress)
?.appointmentView()
?.startSeconds
start == null || start > now
}
if (!couldStillFire) {
Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." }
cancel(applicationContext)
}
return Result.success() return Result.success()
} }
@@ -52,8 +52,13 @@ class ServiceWatchdogManager {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
// ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling
// the CPU out of sleep — if the device is asleep, a restarted service
// couldn't do useful network work anyway. The alarm fires as soon as
// the device is next awake, and the deeper restart layers (15-min
// WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases.
alarmManager.setInexactRepeating( alarmManager.setInexactRepeating(
AlarmManager.ELAPSED_REALTIME_WAKEUP, AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
WATCHDOG_INTERVAL_MS, WATCHDOG_INTERVAL_MS,
pendingIntent, pendingIntent,
@@ -40,7 +40,16 @@ class OkHttpClientFactoryForRelays(
const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120
// Every WebSocket ping on an otherwise-idle cellular connection promotes
// the radio out of its low-power state and pays the multi-second "tail"
// energy cost — per connection. On mobile data the interval is doubled to
// halve those wake-ups while staying under the ~5-minute idle timeout of
// the most aggressive carrier NATs (so connections aren't silently dropped
// between pings). Wifi keeps the tighter interval: pings there are cheap
// and detect dead connections sooner.
const val WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS: Long = 120
const val WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS: Long = 240
} }
val myDispatcher = val myDispatcher =
@@ -78,6 +87,7 @@ class OkHttpClientFactoryForRelays(
fun buildHttpClient( fun buildHttpClient(
proxy: Proxy?, proxy: Proxy?,
timeoutSeconds: Int, timeoutSeconds: Int,
pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS,
): OkHttpClient { ): OkHttpClient {
if (proxy != lastProxy) { if (proxy != lastProxy) {
rootClient.connectionPool.evictAll() rootClient.connectionPool.evictAll()
@@ -91,7 +101,7 @@ class OkHttpClientFactoryForRelays(
.connectTimeout(Duration.ofSeconds(seconds.toLong())) .connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) .pingInterval(Duration.ofSeconds(pingIntervalSeconds))
.build() .build()
} }
@@ -102,12 +112,14 @@ class OkHttpClientFactoryForRelays(
buildHttpClient( buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort), buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE),
) )
fun buildHttpClient(isMobile: Boolean?): OkHttpClient = fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
buildHttpClient( buildHttpClient(
null, null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE),
) )
fun buildTimeout(isMobile: Boolean): Int = fun buildTimeout(isMobile: Boolean): Int =
@@ -117,5 +129,12 @@ class OkHttpClientFactoryForRelays(
DEFAULT_TIMEOUT_ON_WIFI_SECS DEFAULT_TIMEOUT_ON_WIFI_SECS
} }
fun buildPingInterval(isMobile: Boolean): Long =
if (isMobile) {
WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS
} else {
WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS
}
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
} }
@@ -42,10 +42,15 @@ import java.util.concurrent.TimeUnit
/** /**
* Scans the scheduled-post store and publishes posts whose publish time has arrived. * Scans the scheduled-post store and publishes posts whose publish time has arrived.
* *
* - schedule(context): periodic, every 15 min (WorkManager minimum). * - schedule(context): periodic, every 15 min (WorkManager minimum). Only
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came * enqueued while the store holds a PENDING post the
* due while the device was off or while WorkManager * store observer in AppModules schedules it when a
* was deferred by Doze. * pending post appears and cancels it when the last
* one drains, so the worker never wakes the process
* with nothing to publish.
* - scheduleCatchUp(context): one-time, to flush posts that came due while the
* device was off or while WorkManager was deferred
* by Doze.
*/ */
class ScheduledPostWorker( class ScheduledPostWorker(
appContext: Context, appContext: Context,
@@ -107,6 +112,16 @@ class ScheduledPostWorker(
Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" } Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" }
} }
/**
* Ends the 15-minute periodic chain (keeps any catch-up run). Called by the
* store observer in AppModules when the last PENDING post drains, so the
* worker doesn't keep waking the process with nothing to publish.
*/
fun cancelPeriodic(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG) { "cancelPeriodic(): cancelled periodic worker" }
}
fun cancel(context: Context) { fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP) WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)
@@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) {
onCheckedChange = { onCheckedChange = {
enabled = it enabled = it
prefs.setEnabled(it) prefs.setEnabled(it)
// Toggling off doesn't cancel the worker — the worker itself short- // Cancel eagerly on disable so the periodic worker stops waking the
// circuits when isEnabled() returns false. Keeping the schedule alive // process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP
// means flipping it back on takes effect immediately without needing a // observer in AppModules re-schedules on the next relevant RSVP too.
// re-launch via AppModules. if (it) {
if (it) CalendarReminderWorker.schedule(context) CalendarReminderWorker.schedule(context)
} else {
CalendarReminderWorker.cancel(context)
}
}, },
) )
} }
@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -107,7 +108,12 @@ class NostrClient(
// new filters will be sent to the relays and a potential reconnect can // new filters will be sent to the relays and a potential reconnect can
// be triggered. // be triggered.
// Default: STARTS active // Default: STARTS active
private var isActive = true //
// A StateFlow (not a plain Boolean) so the keep-alive loop below can
// suspend without any scheduled timer while the client is inactive —
// e.g. the whole time the app sits in the background after the host
// calls [disconnect].
private val isActiveFlow = MutableStateFlow(true)
/** /**
* Whatches for any changes in the relay list from subscriptions or outbox * Whatches for any changes in the relay list from subscriptions or outbox
@@ -132,16 +138,16 @@ class NostrClient(
// Reconnects all relays that may have disconnected // Reconnects all relays that may have disconnected
override fun connect() { override fun connect() {
isActive = true isActiveFlow.value = true
relayPool.connect() relayPool.connect()
} }
override fun disconnect() { override fun disconnect() {
isActive = false isActiveFlow.value = false
relayPool.disconnect() relayPool.disconnect()
} }
override fun isActive() = isActive override fun isActive() = isActiveFlow.value
class Reconnect( class Reconnect(
val onlyIfChanged: Boolean, val onlyIfChanged: Boolean,
@@ -173,12 +179,19 @@ class NostrClient(
* error code) would stay disconnected forever in the absence of any * error code) would stay disconnected forever in the absence of any
* subscription change. The per-relay [BasicRelayClient] backoff still * subscription change. The per-relay [BasicRelayClient] backoff still
* gates the actual reconnect attempt, so dead relays are not hammered. * gates the actual reconnect attempt, so dead relays are not hammered.
*
* While the client is inactive (the host called [disconnect], e.g. the
* app moved to the background) the loop suspends on [isActiveFlow]
* instead of ticking: no timer fires at all until [connect] flips the
* flag back, saving periodic CPU wake-ups for the whole background
* stretch.
*/ */
private val keepAliveJob = private val keepAliveJob =
scope.launch { scope.launch {
while (true) { while (true) {
isActiveFlow.first { it }
delay(KEEP_ALIVE_INTERVAL_MS) delay(KEEP_ALIVE_INTERVAL_MS)
if (this@NostrClient.isActive) { if (this@NostrClient.isActive()) {
relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false)
} }
} }
@@ -202,7 +215,7 @@ class NostrClient(
) { ) {
val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener)
if (isActive) { if (isActive()) {
activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) { if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd) relayPool.sendIfConnected(relay, cmd)
@@ -225,7 +238,7 @@ class NostrClient(
) { ) {
val relaysToUpdate = activeCounts.addOrUpdate(subId, filters) val relaysToUpdate = activeCounts.addOrUpdate(subId, filters)
if (isActive) { if (isActive()) {
activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) { if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd) relayPool.sendIfConnected(relay, cmd)
@@ -245,7 +258,7 @@ class NostrClient(
) { ) {
val relaysToUpdate = eventOutbox.markAsSending(event, relayList) val relaysToUpdate = eventOutbox.markAsSending(event, relayList)
if (isActive) { if (isActive()) {
eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync) eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync)
// wakes up all the other relays // wakes up all the other relays
@@ -257,14 +270,14 @@ class NostrClient(
val relaysToUpdateReqs = activeRequests.remove(subId) val relaysToUpdateReqs = activeRequests.remove(subId)
val relaysToUpdateCounts = activeCounts.remove(subId) val relaysToUpdateCounts = activeCounts.remove(subId)
if (isActive) { if (isActive()) {
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected) activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected) activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
} }
} }
override fun syncFilters(relay: IRelayClient) { override fun syncFilters(relay: IRelayClient) {
if (isActive) { if (isActive()) {
scope.launch { scope.launch {
activeRequests.syncState(relay.url, relay::sendOrConnectAndSync) activeRequests.syncState(relay.url, relay::sendOrConnectAndSync)
activeCounts.syncState(relay.url, relay::sendOrConnectAndSync) activeCounts.syncState(relay.url, relay::sendOrConnectAndSync)
@@ -337,7 +350,7 @@ class NostrClient(
// The reconnect path is debounced and goes through // The reconnect path is debounced and goes through
// reconnectIfNeedsTo, which respects each relay's exponential // reconnectIfNeedsTo, which respects each relay's exponential
// backoff so we don't hammer dead relays. // backoff so we don't hammer dead relays.
if (isActive && relay.url in allRelays.value) { if (isActive() && relay.url in allRelays.value) {
reconnect(onlyIfChanged = true) reconnect(onlyIfChanged = true)
} }
} }