Push email (the IdleService foreground service holding a long-lived IMAP IDLE connection per account)
stops delivering instantly at or below ~20% battery, and mail only arrives via the 15-minute periodic SyncScheduler fallback (SyncScheduler.kt:27-33) until battery recovers.
What the code does today (confirmed by reading, not yet reproduced on-device)
There is no battery-aware logic anywhere in the push path: IdleService (IdleService.kt) and IdlePushManager have zero reference to battery state, PowerManager, or BatteryManager. The only
battery-related code in the app is BatteryOptimizationManager (push/BatteryOptimizationManager.kt),
and it's only consulted at onboarding time (#49) and for the Settings status row
(SettingsViewModel.kt) — IdleService never calls it. So this reversion isn't app logic choosing to
back off at 20%; it's an emergent side effect of the OS (or OEM battery manager) throttling or killing the
long-lived socket/foreground service, and the app can't currently tell the difference between "IDLE is
fine" and "IDLE just silently stopped" — watchAccount's reconnect loop (IdleService.kt:93-111) keeps
retrying with backoff, but if the OS is refusing background network to a low-battery app, those retries
just keep failing quietly.
Two plausible OS-level mechanisms (not distinguished yet):
Android's system Battery Saver, which restricts background network access once enabled (historically
auto-enables around thresholds like this, and is user-configurable).
OEM-specific aggressive battery management (some manufacturers kill background sockets or foreground
services below a battery threshold, independent of stock Android Doze) — the class of issue
dontkillmyapp.com catalogs. BatteryOptimizationManager's own doc comment already notes it can't
distinguish "Optimized" from "Restricted" (push/BatteryOptimizationManager.kt:24-26), i.e. even a
user who didn't complete the #49 prompt may be on a more restrictive setting than assumed.
Suggested approach
Investigate on a real device at ~20% battery (with logging around watchAccount's reconnect
attempts) to confirm which mechanism is actually responsible — this can't be verified in a sandbox.
Regardless of root cause, the persistent "Watching for new mail" notification
(IdleService.kt:120-142) never changes text/state today, so a user has no visible sign they've
silently fallen back to 15-minute polling. Making that notification (or a Settings status row)
honestly reflect a disconnected/reconnecting state turns a confusing silent degradation into a visible,
explained one.
If it's OS Battery Saver specifically, PowerManager.isPowerSaveMode() / ACTION_POWER_SAVE_MODE_CHANGED can detect it proactively.
If it's the OEM "Restricted" state BatteryOptimizationManager already can't distinguish, consider
surfacing OEM-specific guidance (à la dontkillmyapp.com) from Settings, alongside the existing
battery-allowlist prompt.
Acceptance criteria
Root cause confirmed on a real device.
User gets some visible indication when push has degraded to polling (not silent).
Fix or mitigation implemented per whatever the investigation finds.
Relates to #89 (the other battery-threshold behavior) — likely worth a shared battery-state utility; see
the note there.
## Observed behavior
Push email (the `IdleService` foreground service holding a long-lived IMAP IDLE connection per account)
stops delivering instantly at or below ~20% battery, and mail only arrives via the 15-minute periodic
`SyncScheduler` fallback (`SyncScheduler.kt:27-33`) until battery recovers.
## What the code does today (confirmed by reading, not yet reproduced on-device)
There is **no battery-aware logic anywhere in the push path**: `IdleService` (`IdleService.kt`) and
`IdlePushManager` have zero reference to battery state, `PowerManager`, or `BatteryManager`. The only
battery-related code in the app is `BatteryOptimizationManager` (`push/BatteryOptimizationManager.kt`),
and it's only consulted at onboarding time (#49) and for the Settings status row
(`SettingsViewModel.kt`) — `IdleService` never calls it. So this reversion isn't app logic choosing to
back off at 20%; it's an emergent side effect of the OS (or OEM battery manager) throttling or killing the
long-lived socket/foreground service, and the app can't currently tell the difference between "IDLE is
fine" and "IDLE just silently stopped" — `watchAccount`'s reconnect loop (`IdleService.kt:93-111`) keeps
retrying with backoff, but if the OS is refusing background network to a low-battery app, those retries
just keep failing quietly.
Two plausible OS-level mechanisms (not distinguished yet):
1. Android's system Battery Saver, which restricts background network access once enabled (historically
auto-enables around thresholds like this, and is user-configurable).
2. OEM-specific aggressive battery management (some manufacturers kill background sockets or foreground
services below a battery threshold, independent of stock Android Doze) — the class of issue
dontkillmyapp.com catalogs. `BatteryOptimizationManager`'s own doc comment already notes it can't
distinguish "Optimized" from "Restricted" (`push/BatteryOptimizationManager.kt:24-26`), i.e. even a
user who didn't complete the #49 prompt may be on a more restrictive setting than assumed.
## Suggested approach
1. **Investigate on a real device** at ~20% battery (with logging around `watchAccount`'s reconnect
attempts) to confirm which mechanism is actually responsible — this can't be verified in a sandbox.
2. Regardless of root cause, the persistent "Watching for new mail" notification
(`IdleService.kt:120-142`) never changes text/state today, so a user has no visible sign they've
silently fallen back to 15-minute polling. Making that notification (or a Settings status row)
honestly reflect a disconnected/reconnecting state turns a confusing silent degradation into a visible,
explained one.
3. If it's OS Battery Saver specifically, `PowerManager.isPowerSaveMode()` /
`ACTION_POWER_SAVE_MODE_CHANGED` can detect it proactively.
4. If it's the OEM "Restricted" state `BatteryOptimizationManager` already can't distinguish, consider
surfacing OEM-specific guidance (à la dontkillmyapp.com) from Settings, alongside the existing
battery-allowlist prompt.
## Acceptance criteria
- [ ] Root cause confirmed on a real device.
- [ ] User gets some visible indication when push has degraded to polling (not silent).
- [ ] Fix or mitigation implemented per whatever the investigation finds.
Relates to #89 (the other battery-threshold behavior) — likely worth a shared battery-state utility; see
the note there.
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.
Observed behavior
Push email (the
IdleServiceforeground service holding a long-lived IMAP IDLE connection per account)stops delivering instantly at or below ~20% battery, and mail only arrives via the 15-minute periodic
SyncSchedulerfallback (SyncScheduler.kt:27-33) until battery recovers.What the code does today (confirmed by reading, not yet reproduced on-device)
There is no battery-aware logic anywhere in the push path:
IdleService(IdleService.kt) andIdlePushManagerhave zero reference to battery state,PowerManager, orBatteryManager. The onlybattery-related code in the app is
BatteryOptimizationManager(push/BatteryOptimizationManager.kt),and it's only consulted at onboarding time (#49) and for the Settings status row
(
SettingsViewModel.kt) —IdleServicenever calls it. So this reversion isn't app logic choosing toback off at 20%; it's an emergent side effect of the OS (or OEM battery manager) throttling or killing the
long-lived socket/foreground service, and the app can't currently tell the difference between "IDLE is
fine" and "IDLE just silently stopped" —
watchAccount's reconnect loop (IdleService.kt:93-111) keepsretrying with backoff, but if the OS is refusing background network to a low-battery app, those retries
just keep failing quietly.
Two plausible OS-level mechanisms (not distinguished yet):
auto-enables around thresholds like this, and is user-configurable).
services below a battery threshold, independent of stock Android Doze) — the class of issue
dontkillmyapp.com catalogs.
BatteryOptimizationManager's own doc comment already notes it can'tdistinguish "Optimized" from "Restricted" (
push/BatteryOptimizationManager.kt:24-26), i.e. even auser who didn't complete the #49 prompt may be on a more restrictive setting than assumed.
Suggested approach
watchAccount's reconnectattempts) to confirm which mechanism is actually responsible — this can't be verified in a sandbox.
(
IdleService.kt:120-142) never changes text/state today, so a user has no visible sign they'vesilently fallen back to 15-minute polling. Making that notification (or a Settings status row)
honestly reflect a disconnected/reconnecting state turns a confusing silent degradation into a visible,
explained one.
PowerManager.isPowerSaveMode()/ACTION_POWER_SAVE_MODE_CHANGEDcan detect it proactively.BatteryOptimizationManageralready can't distinguish, considersurfacing OEM-specific guidance (à la dontkillmyapp.com) from Settings, alongside the existing
battery-allowlist prompt.
Acceptance criteria
Relates to #89 (the other battery-threshold behavior) — likely worth a shared battery-state utility; see
the note there.