feat(sync): gate full-content fetch on Wi-Fi/battery; IDLE polls at low battery #109

Merged
JMR-dev merged 2 commits from feat-sync-battery-network-policy into main 2026-07-02 04:49:07 +00:00
JMR-dev commented 2026-07-02 03:09:25 +00:00 (Migrated from github.com)

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

## 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)
Sign in to join this conversation.