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

View File

@@ -158,13 +158,17 @@ Google Play Services infrastructure.
### 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.
**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
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
wake the device from sleep, ensuring the check happens even in Doze.
delivered), the watchdog will catch it within 5 minutes of the device being awake.
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)

View File

@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
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.connectivity.ConnectivityManager
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.speedLogger.RelaySpeedLogger
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.ScheduledPostWorker
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.stats.RelayStats
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.SurgeDnsStore
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.TOR_ELECTRUMX_SERVERS
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.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
@@ -887,17 +892,48 @@ class AppModules(
// starts observing LocalCache for notification-worthy events
notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
// Keep the scheduled-posts worker (15-min periodic + one-time catch-up)
// enqueued exactly while the store holds a PENDING post. An always-on
// periodic worker wakes — and often cold-starts — the whole process every
// 15 minutes forever, even for users who never schedule a post. The store
// 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
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as
// ACCEPTED. The 15-min periodic scanner is only scheduled while it can
// plausibly fire: this observer enqueues it when an accepted RSVP lands in
// LocalCache, and the worker cancels its own chain when the cache holds
// 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
applicationIOScope.launch {

View File

@@ -47,6 +47,13 @@ import java.util.concurrent.TimeUnit
* 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
* 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(
appContext: Context,
@@ -55,7 +62,10 @@ class CalendarReminderWorker(
override suspend fun doWork(): Result {
val prefs = CalendarReminderPrefs(applicationContext)
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()
}
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.
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()
}

View File

@@ -52,8 +52,13 @@ class ServiceWatchdogManager {
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.ELAPSED_REALTIME_WAKEUP,
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS,
WATCHDOG_INTERVAL_MS,
pendingIntent,

View File

@@ -40,7 +40,16 @@ class OkHttpClientFactoryForRelays(
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
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 =
@@ -78,6 +87,7 @@ class OkHttpClientFactoryForRelays(
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS,
): OkHttpClient {
if (proxy != lastProxy) {
rootClient.connectionPool.evictAll()
@@ -91,7 +101,7 @@ class OkHttpClientFactoryForRelays(
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS))
.pingInterval(Duration.ofSeconds(pingIntervalSeconds))
.build()
}
@@ -102,12 +112,14 @@ class OkHttpClientFactoryForRelays(
buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
buildHttpClient(
null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildTimeout(isMobile: Boolean): Int =
@@ -117,5 +129,12 @@ class OkHttpClientFactoryForRelays(
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))
}

View File

@@ -42,10 +42,15 @@ import java.util.concurrent.TimeUnit
/**
* Scans the scheduled-post store and publishes posts whose publish time has arrived.
*
* - schedule(context): periodic, every 15 min (WorkManager minimum).
* - scheduleCatchUp(context): one-time, on app start, to flush posts that came
* due while the device was off or while WorkManager
* was deferred by Doze.
* - schedule(context): periodic, every 15 min (WorkManager minimum). Only
* enqueued while the store holds a PENDING post the
* store observer in AppModules schedules it when a
* 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(
appContext: Context,
@@ -107,6 +112,16 @@ class ScheduledPostWorker(
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) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP)

View File

@@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) {
onCheckedChange = {
enabled = it
prefs.setEnabled(it)
// Toggling off doesn't cancel the worker — the worker itself short-
// circuits when isEnabled() returns false. Keeping the schedule alive
// means flipping it back on takes effect immediately without needing a
// re-launch via AppModules.
if (it) CalendarReminderWorker.schedule(context)
// Cancel eagerly on disable so the periodic worker stops waking the
// process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP
// observer in AppModules re-schedules on the next relevant RSVP too.
if (it) {
CalendarReminderWorker.schedule(context)
} else {
CalendarReminderWorker.cancel(context)
}
},
)
}

View File

@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
@@ -107,7 +108,12 @@ class NostrClient(
// new filters will be sent to the relays and a potential reconnect can
// be triggered.
// 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
@@ -132,16 +138,16 @@ class NostrClient(
// Reconnects all relays that may have disconnected
override fun connect() {
isActive = true
isActiveFlow.value = true
relayPool.connect()
}
override fun disconnect() {
isActive = false
isActiveFlow.value = false
relayPool.disconnect()
}
override fun isActive() = isActive
override fun isActive() = isActiveFlow.value
class Reconnect(
val onlyIfChanged: Boolean,
@@ -173,12 +179,19 @@ class NostrClient(
* error code) would stay disconnected forever in the absence of any
* subscription change. The per-relay [BasicRelayClient] backoff still
* 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 =
scope.launch {
while (true) {
isActiveFlow.first { it }
delay(KEEP_ALIVE_INTERVAL_MS)
if (this@NostrClient.isActive) {
if (this@NostrClient.isActive()) {
relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false)
}
}
@@ -202,7 +215,7 @@ class NostrClient(
) {
val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener)
if (isActive) {
if (isActive()) {
activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd)
@@ -225,7 +238,7 @@ class NostrClient(
) {
val relaysToUpdate = activeCounts.addOrUpdate(subId, filters)
if (isActive) {
if (isActive()) {
activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd ->
if (cmd is CloseCmd || cmd is AuthCmd) {
relayPool.sendIfConnected(relay, cmd)
@@ -245,7 +258,7 @@ class NostrClient(
) {
val relaysToUpdate = eventOutbox.markAsSending(event, relayList)
if (isActive) {
if (isActive()) {
eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync)
// wakes up all the other relays
@@ -257,14 +270,14 @@ class NostrClient(
val relaysToUpdateReqs = activeRequests.remove(subId)
val relaysToUpdateCounts = activeCounts.remove(subId)
if (isActive) {
if (isActive()) {
activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected)
activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected)
}
}
override fun syncFilters(relay: IRelayClient) {
if (isActive) {
if (isActive()) {
scope.launch {
activeRequests.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
// reconnectIfNeedsTo, which respects each relay's exponential
// 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)
}
}