fix(push): stop IdleService dataSync FGS crash-loop on exhausted 24h cap (#354) #366

Merged
JMR-dev merged 1 commits from fix-354-idleservice-fgs into main 2026-07-06 00:36:28 +00:00
JMR-dev commented 2026-07-06 00:17:55 +00:00 (Migrated from github.com)

Fixes #354.

Root cause

After #302's dataSync FGS runtime-cap handler fallBackToPeriodicSync() stops IdleService, the service was restarted — both by START_STICKY null-intent redelivery and by explicit startForegroundService calls (ensurePushStarted() on app foreground, account/settings changes) — and onStartCommand unconditionally called startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC) while the rolling-24h budget was still exhausted. The platform rejected that start with ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync; it was uncaught, the process crashed, and START_STICKY restarted straight back into the same rejection — a crash loop until the 24h window freed budget.

Fix (IdleService.kt)

  1. onStartCommand now returns START_NOT_STICKY (was START_STICKY). Push is app-managed — LibreMailApplication's settings/account collector and ensurePushStarted() deterministically (re)start the service whenever it should run — so the platform's sticky null-intent auto-restart was redundant and fired exactly in the states that can't legally start a dataSync FGS.
  2. Guarded foreground start via a new pure, JVM-testable seam IdleForegroundStarter. A ForegroundServiceStartNotAllowedException (caught via its IllegalStateException supertype, so no minSdk-29 class-load gate is needed) no longer propagates; instead the service degrades exactly like the cap handler — schedulePeriodicSync(), keep the degraded PushMode.POLLING ("instant delivery paused") notification, and stopSelf() promptly (the start came via startForegroundService, so a prompt stop avoids the "did not call startForeground in time" ANR). Any non-ISE still propagates.
  3. Cap-window hardening. fallBackToPeriodicSync() (and a caught rejection) records the cap event via SystemClock.elapsedRealtime(); while still inside the window, onStartCommand skips the now-guaranteed-illegal foreground start entirely (schedule periodic + stopSelf()). The window is anchored to the last real cap event (never refreshed by a skip), so it expires and lets a later restart re-probe — safe, because a still-capped rejection is caught.
  4. #302's onTimeout stop path is kept fast/synchronous so ForegroundServiceDidNotStopInTimeException stays mitigated.

PII-free AppLog.w/AppLog.i on the degrade paths (no account/email/intent contents; the caught throwable is auto-scrubbed by AppLog).

Tests

  • Unit (IdleForegroundStarterTest, JVM — no Robolectric in this repo, so the decision logic is the extracted seam): returns START_NOT_STICKY; a rejected startForeground is caught and routed to degrade without propagating; an active cap window skips the attempt; a non-IllegalStateException propagates unchanged.
  • Instrumented (IdleServiceForegroundStartInstrumentedTest, alongside PushStatusNotificationInstrumentedTest): drives the same decision seam on a real Context — a rejected start schedules the periodic-sync fallback, builds the degraded "instant delivery paused" notification, and skips IDLE watching; the cap-window path skips the start attempt and still degrades.

Local gate

Fast CI gate green: assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt — BUILD SUCCESSFUL. The full multi-API E2E matrix (API 29–37, incl. the 16 KB API-37 preview) runs in CI.

🤖 Generated with Claude Code

Fixes #354. ## Root cause After #302's dataSync FGS runtime-cap handler `fallBackToPeriodicSync()` stops `IdleService`, the service was restarted — both by `START_STICKY` null-intent redelivery and by explicit `startForegroundService` calls (`ensurePushStarted()` on app foreground, account/settings changes) — and `onStartCommand` unconditionally called `startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)` while the rolling-24h budget was still exhausted. The platform rejected that start with `ForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync`; it was uncaught, the process crashed, and `START_STICKY` restarted straight back into the same rejection — a crash **loop** until the 24h window freed budget. ## Fix (`IdleService.kt`) 1. **`onStartCommand` now returns `START_NOT_STICKY`** (was `START_STICKY`). Push is app-managed — `LibreMailApplication`'s settings/account collector and `ensurePushStarted()` deterministically (re)start the service whenever it should run — so the platform's sticky null-intent auto-restart was redundant and fired exactly in the states that can't legally start a dataSync FGS. 2. **Guarded foreground start** via a new pure, JVM-testable seam `IdleForegroundStarter`. A `ForegroundServiceStartNotAllowedException` (caught via its `IllegalStateException` supertype, so no `minSdk`-29 class-load gate is needed) no longer propagates; instead the service degrades exactly like the cap handler — `schedulePeriodicSync()`, keep the degraded `PushMode.POLLING` ("instant delivery paused") notification, and `stopSelf()` promptly (the start came via `startForegroundService`, so a prompt stop avoids the "did not call startForeground in time" ANR). Any non-ISE still propagates. 3. **Cap-window hardening.** `fallBackToPeriodicSync()` (and a caught rejection) records the cap event via `SystemClock.elapsedRealtime()`; while still inside the window, `onStartCommand` skips the now-guaranteed-illegal foreground start entirely (schedule periodic + `stopSelf()`). The window is anchored to the last real cap event (never refreshed by a skip), so it expires and lets a later restart re-probe — safe, because a still-capped rejection is caught. 4. #302's `onTimeout` stop path is kept fast/synchronous so `ForegroundServiceDidNotStopInTimeException` stays mitigated. PII-free `AppLog.w`/`AppLog.i` on the degrade paths (no account/email/intent contents; the caught throwable is auto-scrubbed by `AppLog`). ## Tests - **Unit** (`IdleForegroundStarterTest`, JVM — no Robolectric in this repo, so the decision logic is the extracted seam): returns `START_NOT_STICKY`; a rejected `startForeground` is caught and routed to degrade **without propagating**; an active cap window skips the attempt; a non-`IllegalStateException` propagates unchanged. - **Instrumented** (`IdleServiceForegroundStartInstrumentedTest`, alongside `PushStatusNotificationInstrumentedTest`): drives the same decision seam on a real `Context` — a rejected start schedules the periodic-sync fallback, builds the degraded "instant delivery paused" notification, and skips IDLE watching; the cap-window path skips the start attempt and still degrades. ## Local gate Fast CI gate green: `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt` — **BUILD SUCCESSFUL**. The full multi-API E2E matrix (API 29–37, incl. the 16 KB API-37 preview) runs in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.