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)
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.
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.
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.
#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.
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #354.
Root cause
After #302's dataSync FGS runtime-cap handler
fallBackToPeriodicSync()stopsIdleService, the service was restarted — both bySTART_STICKYnull-intent redelivery and by explicitstartForegroundServicecalls (ensurePushStarted()on app foreground, account/settings changes) — andonStartCommandunconditionally calledstartForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)while the rolling-24h budget was still exhausted. The platform rejected that start withForegroundServiceStartNotAllowedException: Time limit already exhausted for foreground service type dataSync; it was uncaught, the process crashed, andSTART_STICKYrestarted straight back into the same rejection — a crash loop until the 24h window freed budget.Fix (
IdleService.kt)onStartCommandnow returnsSTART_NOT_STICKY(wasSTART_STICKY). Push is app-managed —LibreMailApplication's settings/account collector andensurePushStarted()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.IdleForegroundStarter. AForegroundServiceStartNotAllowedException(caught via itsIllegalStateExceptionsupertype, so nominSdk-29 class-load gate is needed) no longer propagates; instead the service degrades exactly like the cap handler —schedulePeriodicSync(), keep the degradedPushMode.POLLING("instant delivery paused") notification, andstopSelf()promptly (the start came viastartForegroundService, so a prompt stop avoids the "did not call startForeground in time" ANR). Any non-ISE still propagates.fallBackToPeriodicSync()(and a caught rejection) records the cap event viaSystemClock.elapsedRealtime(); while still inside the window,onStartCommandskips 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.onTimeoutstop path is kept fast/synchronous soForegroundServiceDidNotStopInTimeExceptionstays mitigated.PII-free
AppLog.w/AppLog.ion the degrade paths (no account/email/intent contents; the caught throwable is auto-scrubbed byAppLog).Tests
IdleForegroundStarterTest, JVM — no Robolectric in this repo, so the decision logic is the extracted seam): returnsSTART_NOT_STICKY; a rejectedstartForegroundis caught and routed to degrade without propagating; an active cap window skips the attempt; a non-IllegalStateExceptionpropagates unchanged.IdleServiceForegroundStartInstrumentedTest, alongsidePushStatusNotificationInstrumentedTest): drives the same decision seam on a realContext— 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