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.
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)
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.
Closes #302
Problem
IdleServiceruns continuously as aFOREGROUND_SERVICE_TYPE_DATA_SYNCforeground service, and push is ON by default. WithtargetSdk 37, Android 14+'sdataSyncFGS runtime cap (~6h per rolling 24h) callsService.onTimeout(...)and then force-stops the service (throwing a system FGS-timeout exception) if the service doesn't stop itself.IdleServiceoverrodeonStartCommand/onDestroy/onBindbut notonTimeout, 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, soMainActivity.ensurePushStartedcouldn't recover push until the next window.Fix
onTimeout(startId)(deprecated, API 34) andonTimeout(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.fallBackToPeriodicSync()mirrors the existing low-batteryPushMode.POLLINGpath:SyncScheduler.schedulePeriodicSync(),UPDATEso it's a no-op if unchanged) as the fallback,stopForeground(STOP_FOREGROUND_DETACH)so the notification survives after we drop foreground state,stopSelf()—onDestroycancels the scope, closing the IDLE connections. We never leave adataSyncFGS running past its cap (the exact condition the platform kills on).notify()is guarded by aPOST_NOTIFICATIONScheck, matchingMailNotifier.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
PushStatusNotificationTest): the text choice is extracted into a purePushStatusNotification.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.PushStatusNotificationInstrumentedTest): asserts the built notification shows the new timed-out text (runs in CI's E2E matrix).:app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:lintDebug :app:ktlintCheck :app:detekt— all green.🤖 Generated with Claude Code