diff --git a/PULL_NOTIFICATION.md b/PULL_NOTIFICATION.md index ef71d19e2a..bd407f484b 100644 --- a/PULL_NOTIFICATION.md +++ b/PULL_NOTIFICATION.md @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..fbdd4b444e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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. - ScheduledPostWorker.schedule(appContext) - ScheduledPostWorker.scheduleCatchUp(appContext) + // 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(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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0cb0b6f217..4560fe86e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt index dc9fd2ccdc..1d2b317928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index fbce710e6f..1f826aad05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index b9b339332f..6663d98463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index 8299ce35d4..6959718e23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -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) + } }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 98d8f6a377..2e98749f34 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -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) } }