fix(push): handle Service.onTimeout to survive the dataSync FGS runtime cap #314

Merged
JMR-dev merged 2 commits from fix-302-idleservice-fgs-timeout into main 2026-07-04 07:26:16 +00:00
JMR-dev commented 2026-07-04 07:09:34 +00:00 (Migrated from github.com)

Closes #302

Problem

IdleService runs continuously as a FOREGROUND_SERVICE_TYPE_DATA_SYNC foreground service, and push is ON by default. With targetSdk 37, Android 14+'s dataSync FGS runtime cap (~6h per rolling 24h) calls Service.onTimeout(...) and then force-stops the service (throwing a system FGS-timeout exception) if the service doesn't stop itself. IdleService overrode onStartCommand/onDestroy/onBind but not onTimeout, so after ~6 cumulative hours push silently died and the app hit the exception. On API 35+ the budget is cumulative per-24h and a restart doesn't reset it, so MainActivity.ensurePushStarted couldn't recover push until the next window.

Fix

  • Override both onTimeout(startId) (deprecated, API 34) and onTimeout(startId, fgsType) (API 35+) — the platform calls the single-arg form on Android 14 and the two-arg form on 15+; both route to one idempotent handler.
  • The handler fallBackToPeriodicSync() mirrors the existing low-battery PushMode.POLLING path:
    • re-asserts the already-scheduled 15-minute periodic sync (SyncScheduler.schedulePeriodicSync(), UPDATE so it's a no-op if unchanged) as the fallback,
    • swaps the persistent notification to a new "Instant delivery paused — checking every 15 minutes for now" text and stopForeground(STOP_FOREGROUND_DETACH) so the notification survives after we drop foreground state,
    • stopSelf() — onDestroy cancels the scope, closing the IDLE connections. We never leave a dataSync FGS running past its cap (the exact condition the platform kills on).
    • The notify() is guarded by a POST_NOTIFICATIONS check, matching MailNotifier.

Fallback path

FGS timeout → onTimeout → schedule periodic sync (WorkManager 15-min) + degraded notification + stopForeground(DETACH) + stopSelf. Mail then arrives via the periodic sync until push is started again (next app foreground / cap reset), exactly like the low-battery fallback.

Tests

  • JVM unit test (PushStatusNotificationTest): the text choice is extracted into a pure PushStatusNotification.statusTextRes(mode, timedOut) seam and unit-tested for IDLE / low-battery / timed-out (incl. timed-out taking precedence while push is nominally IDLE). No emulator.
  • Instrumented test (PushStatusNotificationInstrumentedTest): asserts the built notification shows the new timed-out text (runs in CI's E2E matrix).
  • Validated locally with no emulator: :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:lintDebug :app:ktlintCheck :app:detekt — all green.

🤖 Generated with Claude Code

Closes #302 ## Problem `IdleService` runs continuously as a `FOREGROUND_SERVICE_TYPE_DATA_SYNC` foreground service, and push is ON by default. With `targetSdk 37`, Android 14+'s `dataSync` FGS runtime cap (~6h per rolling 24h) calls `Service.onTimeout(...)` and then **force-stops the service (throwing a system FGS-timeout exception)** if the service doesn't stop itself. `IdleService` overrode `onStartCommand`/`onDestroy`/`onBind` but **not `onTimeout`**, so after ~6 cumulative hours push silently died and the app hit the exception. On API 35+ the budget is cumulative per-24h and a restart doesn't reset it, so `MainActivity.ensurePushStarted` couldn't recover push until the next window. ## Fix - **Override both `onTimeout(startId)` (deprecated, API 34) and `onTimeout(startId, fgsType)` (API 35+)** — the platform calls the single-arg form on Android 14 and the two-arg form on 15+; both route to one idempotent handler. - The handler `fallBackToPeriodicSync()` mirrors the existing low-battery `PushMode.POLLING` path: - re-asserts the already-scheduled 15-minute periodic sync (`SyncScheduler.schedulePeriodicSync()`, `UPDATE` so it's a no-op if unchanged) as the fallback, - swaps the persistent notification to a new "Instant delivery paused — checking every 15 minutes for now" text and **`stopForeground(STOP_FOREGROUND_DETACH)`** so the notification survives after we drop foreground state, - **`stopSelf()`** — `onDestroy` cancels the scope, closing the IDLE connections. We never leave a `dataSync` FGS running past its cap (the exact condition the platform kills on). - The `notify()` is guarded by a `POST_NOTIFICATIONS` check, matching `MailNotifier`. ## Fallback path FGS timeout → `onTimeout` → schedule periodic sync (WorkManager 15-min) + degraded notification + `stopForeground(DETACH)` + `stopSelf`. Mail then arrives via the periodic sync until push is started again (next app foreground / cap reset), exactly like the low-battery fallback. ## Tests - **JVM unit test** (`PushStatusNotificationTest`): the text choice is extracted into a pure `PushStatusNotification.statusTextRes(mode, timedOut)` seam and unit-tested for IDLE / low-battery / timed-out (incl. timed-out taking precedence while push is nominally IDLE). No emulator. - **Instrumented test** (`PushStatusNotificationInstrumentedTest`): asserts the built notification shows the new timed-out text (runs in CI's E2E matrix). - Validated locally with no emulator: `:app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:lintDebug :app:ktlintCheck :app:detekt` — all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.