One PR for the "Sync & storage" trio (#88, #89, #90) rather than three: all three behaviors hang off the same new battery/policy core, so splitting them would have meant either duplicating that core or stacking PRs that can't land independently anyway.
Shared core
power/BatteryStatusProvider — thin Android touchpoint reading battery state two ways: a one-shot current() from BatteryManager (BATTERY_PROPERTY_CAPACITY + isCharging), and a status(): Flow<BatteryStatus> built on the sticky ACTION_BATTERY_CHANGED broadcast (runtime-registered via ContextCompat.registerReceiver with RECEIVER_NOT_EXPORTED, unregistered when collection stops). Unreadable values degrade to "full battery, not charging" so bad data can only ever fail open — never pause sync.
data/sync/SyncResourcePolicy — a pure, Android-free decision object holding every gate: the prefetch decision (fetch policy × network × battery), the low-battery threshold (≤20%), the push-mode state machine with hysteresis, and the Flow fold the IDLE service consumes. Exhaustively unit-tested.
All gates are runtime-only and self-reverting: nothing writes a setting; battery recovering / plugging in / regaining Wi-Fi restores normal behavior by itself.
Charging exempts from every battery gate (resolving #89's open question): a phone on the charger at 15% is not under battery pressure, so it keeps prefetching and keeps IDLE alive.
Per issue
Closes#88 — FetchPolicy now defaults to WIFI_ONLY in both places the issue calls out: the AppSettings in-memory default and the DataStore-read fallback in toAppSettings(), so fresh installs and existing installs that never touched the setting stop bulk-downloading full bodies/attachments over cellular. An explicitly chosen policy (incl. ALWAYS) is stored and always wins; the existing Settings radio group already surfaces all three options, so no new UI was needed.
Closes#89 — MailSyncer.prefetchIfEnabled() now asks SyncResourcePolicy.shouldPrefetchContent(policy, ::isUnmetered, battery): at ≤20% (not charging) the aggressive content prefetch pauses for every fetch policy, and resumes on the next sync once above the threshold (per the issue's acceptance criteria: next sync, not instantly). The header sync — new-mail detection and notifications — is deliberately not gated: mail still arrives; only full-content pre-caching is deferred. Network state stays lazily consulted (only WIFI_ONLY queries ConnectivityManager).
Closes#90 — IdleService now observes SyncResourcePolicy.pushModes(...) combined with the account list:
At ≤20% battery it proactively and cleanly closes every IDLE connection (watcher cancellation runs idle()'s existing cancel path, which closes the IMAP store) instead of letting the OS strangle the socket while the backoff loop burns battery on doomed reconnects.
The persistent foreground notification flips to "Battery low — checking every 15 minutes until it recovers" — the silent degradation the issue describes is now visible and explained (also logged via AppLog, so it shows in debug reports).
Mail keeps arriving via the 15-minute periodic SyncScheduler work (always scheduled at app start with KEEP; re-asserted on entering polling mode as belt-and-braces).
Recovery: IDLE resumes at ≥25% or immediately when charging — the 20/25 band is hysteresis so a battery jittering around the threshold can't flap connections up and down. On resume, idle()'s on-connect sync catches up anything missed.
The service itself stays up (it's already running and holds no wakelock/socket while polling); that means resuming IDLE needs no new foreground-service start from the background, which Android would block.
Remaining from #90's checklist for the maintainer: "root cause confirmed on a real device" — that can't be done in this environment. This PR makes the behavior deterministic and observable regardless of which OS mechanism was responsible (the AppLog lines around mode changes + the reconnect-loop warnings give the on-device investigation its logging).
Tests
SyncResourcePolicyTest (new): threshold boundaries (20/21/25), charging exemptions, full fetch-policy × network × battery matrix, lazy network consultation, and Turbine tests of the pushModes stream — start-low, drop, no-flap jitter inside the hysteresis band, plug-in/unplug transitions.
AppSettingsTest (new): WIFI_ONLY as the in-memory default and the DataStore fallback (incl. unrecognized stored values); explicit stored choice respected.
MailSyncerTest (extended): low battery pauses prefetch for ALWAYS while the header sync still succeeds; exact-20% boundary; charging-at-15% still prefetches; resume at 21%.
Existing E2E SettingsScreenTest sets its own starting policy explicitly, so it is unaffected by the default change.
Fast CI gate (assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt) plus compileDebugAndroidTestKotlin pass locally after rebasing onto latest main.
## Summary
One PR for the "Sync & storage" trio (#88, #89, #90) rather than three: all three behaviors hang off the same new battery/policy core, so splitting them would have meant either duplicating that core or stacking PRs that can't land independently anyway.
### Shared core
- **`power/BatteryStatusProvider`** — thin Android touchpoint reading battery state two ways: a one-shot `current()` from `BatteryManager` (`BATTERY_PROPERTY_CAPACITY` + `isCharging`), and a `status(): Flow<BatteryStatus>` built on the sticky `ACTION_BATTERY_CHANGED` broadcast (runtime-registered via `ContextCompat.registerReceiver` with `RECEIVER_NOT_EXPORTED`, unregistered when collection stops). Unreadable values degrade to "full battery, not charging" so bad data can only ever fail open — never pause sync.
- **`data/sync/SyncResourcePolicy`** — a pure, Android-free decision object holding every gate: the prefetch decision (fetch policy × network × battery), the low-battery threshold (≤20%), the push-mode state machine with hysteresis, and the `Flow` fold the IDLE service consumes. Exhaustively unit-tested.
- All gates are **runtime-only and self-reverting**: nothing writes a setting; battery recovering / plugging in / regaining Wi-Fi restores normal behavior by itself.
- **Charging exempts from every battery gate** (resolving #89's open question): a phone on the charger at 15% is not under battery pressure, so it keeps prefetching and keeps IDLE alive.
### Per issue
**Closes #88** — `FetchPolicy` now defaults to `WIFI_ONLY` in *both* places the issue calls out: the `AppSettings` in-memory default and the DataStore-read fallback in `toAppSettings()`, so fresh installs **and** existing installs that never touched the setting stop bulk-downloading full bodies/attachments over cellular. An explicitly chosen policy (incl. `ALWAYS`) is stored and always wins; the existing Settings radio group already surfaces all three options, so no new UI was needed.
**Closes #89** — `MailSyncer.prefetchIfEnabled()` now asks `SyncResourcePolicy.shouldPrefetchContent(policy, ::isUnmetered, battery)`: at ≤20% (not charging) the aggressive content prefetch pauses for **every** fetch policy, and resumes on the next sync once above the threshold (per the issue's acceptance criteria: next sync, not instantly). The header sync — new-mail detection and notifications — is deliberately not gated: mail still arrives; only full-content pre-caching is deferred. Network state stays lazily consulted (only `WIFI_ONLY` queries `ConnectivityManager`).
**Closes #90** — `IdleService` now observes `SyncResourcePolicy.pushModes(...)` combined with the account list:
- At ≤20% battery it **proactively and cleanly closes every IDLE connection** (watcher cancellation runs `idle()`'s existing cancel path, which closes the IMAP store) instead of letting the OS strangle the socket while the backoff loop burns battery on doomed reconnects.
- The persistent foreground notification flips to *"Battery low — checking every 15 minutes until it recovers"* — the silent degradation the issue describes is now **visible and explained** (also logged via `AppLog`, so it shows in debug reports).
- Mail keeps arriving via the 15-minute periodic `SyncScheduler` work (always scheduled at app start with `KEEP`; re-asserted on entering polling mode as belt-and-braces).
- Recovery: IDLE resumes at ≥25% **or immediately when charging** — the 20/25 band is hysteresis so a battery jittering around the threshold can't flap connections up and down. On resume, `idle()`'s on-connect sync catches up anything missed.
- The service itself stays up (it's already running and holds no wakelock/socket while polling); that means resuming IDLE needs **no new foreground-service start from the background**, which Android would block.
Remaining from #90's checklist for the maintainer: *"root cause confirmed on a real device"* — that can't be done in this environment. This PR makes the behavior deterministic and observable regardless of which OS mechanism was responsible (the `AppLog` lines around mode changes + the reconnect-loop warnings give the on-device investigation its logging).
## Tests
- `SyncResourcePolicyTest` (new): threshold boundaries (20/21/25), charging exemptions, full fetch-policy × network × battery matrix, lazy network consultation, and Turbine tests of the `pushModes` stream — start-low, drop, **no-flap jitter inside the hysteresis band**, plug-in/unplug transitions.
- `AppSettingsTest` (new): `WIFI_ONLY` as the in-memory default *and* the DataStore fallback (incl. unrecognized stored values); explicit stored choice respected.
- `MailSyncerTest` (extended): low battery pauses prefetch for `ALWAYS` while the header sync still succeeds; exact-20% boundary; charging-at-15% still prefetches; resume at 21%.
- Existing E2E `SettingsScreenTest` sets its own starting policy explicitly, so it is unaffected by the default change.
Fast CI gate (`assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`) plus `compileDebugAndroidTestKotlin` pass locally after rebasing onto latest `main`.
🤖 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.
Summary
One PR for the "Sync & storage" trio (#88, #89, #90) rather than three: all three behaviors hang off the same new battery/policy core, so splitting them would have meant either duplicating that core or stacking PRs that can't land independently anyway.
Shared core
power/BatteryStatusProvider— thin Android touchpoint reading battery state two ways: a one-shotcurrent()fromBatteryManager(BATTERY_PROPERTY_CAPACITY+isCharging), and astatus(): Flow<BatteryStatus>built on the stickyACTION_BATTERY_CHANGEDbroadcast (runtime-registered viaContextCompat.registerReceiverwithRECEIVER_NOT_EXPORTED, unregistered when collection stops). Unreadable values degrade to "full battery, not charging" so bad data can only ever fail open — never pause sync.data/sync/SyncResourcePolicy— a pure, Android-free decision object holding every gate: the prefetch decision (fetch policy × network × battery), the low-battery threshold (≤20%), the push-mode state machine with hysteresis, and theFlowfold the IDLE service consumes. Exhaustively unit-tested.Per issue
Closes #88 —
FetchPolicynow defaults toWIFI_ONLYin both places the issue calls out: theAppSettingsin-memory default and the DataStore-read fallback intoAppSettings(), so fresh installs and existing installs that never touched the setting stop bulk-downloading full bodies/attachments over cellular. An explicitly chosen policy (incl.ALWAYS) is stored and always wins; the existing Settings radio group already surfaces all three options, so no new UI was needed.Closes #89 —
MailSyncer.prefetchIfEnabled()now asksSyncResourcePolicy.shouldPrefetchContent(policy, ::isUnmetered, battery): at ≤20% (not charging) the aggressive content prefetch pauses for every fetch policy, and resumes on the next sync once above the threshold (per the issue's acceptance criteria: next sync, not instantly). The header sync — new-mail detection and notifications — is deliberately not gated: mail still arrives; only full-content pre-caching is deferred. Network state stays lazily consulted (onlyWIFI_ONLYqueriesConnectivityManager).Closes #90 —
IdleServicenow observesSyncResourcePolicy.pushModes(...)combined with the account list:idle()'s existing cancel path, which closes the IMAP store) instead of letting the OS strangle the socket while the backoff loop burns battery on doomed reconnects.AppLog, so it shows in debug reports).SyncSchedulerwork (always scheduled at app start withKEEP; re-asserted on entering polling mode as belt-and-braces).idle()'s on-connect sync catches up anything missed.Remaining from #90's checklist for the maintainer: "root cause confirmed on a real device" — that can't be done in this environment. This PR makes the behavior deterministic and observable regardless of which OS mechanism was responsible (the
AppLoglines around mode changes + the reconnect-loop warnings give the on-device investigation its logging).Tests
SyncResourcePolicyTest(new): threshold boundaries (20/21/25), charging exemptions, full fetch-policy × network × battery matrix, lazy network consultation, and Turbine tests of thepushModesstream — start-low, drop, no-flap jitter inside the hysteresis band, plug-in/unplug transitions.AppSettingsTest(new):WIFI_ONLYas the in-memory default and the DataStore fallback (incl. unrecognized stored values); explicit stored choice respected.MailSyncerTest(extended): low battery pauses prefetch forALWAYSwhile the header sync still succeeds; exact-20% boundary; charging-at-15% still prefetches; resume at 21%.SettingsScreenTestsets its own starting policy explicitly, so it is unaffected by the default change.Fast CI gate (
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt) pluscompileDebugAndroidTestKotlinpass locally after rebasing onto latestmain.🤖 Generated with Claude Code