Timely mail delivery depends on two mechanisms today, both of which Android's battery
management throttles:
Push:IdleService — a dataSync foreground service holding an IMAP IDLE connection
per account for instant delivery (opt-in, settings.pushIdle, on by default; toggled under Advanced Settings).
Polling:SyncScheduler.schedulePeriodicSync() — a WorkManager periodic job at the
15-minute floor with a CONNECTED network constraint.
Doze, App Standby buckets, and especially the per-app Restricted battery setting defer
those WorkManager jobs and let the OS kill the foreground service, so mail arrives late or only
when the app is next opened. IdlePushManager.start() already swallows ForegroundServiceStartNotAllowedException when a background start is blocked — moving the app
onto the battery allowlist reduces how often the OS tears IDLE down in the first place.
There's no battery handling in the app today (no PowerManager / REQUEST_IGNORE_BATTERY_OPTIMIZATIONS anywhere; the manifest declares none). MainActivity.NotificationPermissionEffect is the pattern to mirror for a one-time,
first-run prompt.
Two distinct Android surfaces, often conflated:
Doze allowlist ("ignore battery optimizations"). Read with PowerManager.isIgnoringBatteryOptimizations(pkg). Changeable via the direct system dialog ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS (needs the restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission) or by deep-linking to the settings
screen with no special permission.
Per-app Unrestricted / Optimized / Restricted (App info → Battery, refined on Android
12+). No API sets this — we can only deep-link to app details and guide the user. Note isIgnoringBatteryOptimizations returns true for Unrestricted and false for both Optimized and Restricted, so it can't single out the harmful "Restricted" state.
Chosen approach — guided deep-link (F-Droid-safe). We explain the benefit, then send the
user to the system battery screen to flip the app to Unrestricted; we do not fire the ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog. Google Play restricts that permission to an
approved set of use cases (rejection risk — see #17), whereas the deep-link path needs no extra
permission and is safe on both Play and F-Droid (#16), consistent with the project's F-Droid-first
posture. (A one-tap direct dialog could later be added behind an F-Droid-only flavor if we want it.)
Scope
Gate the feature on "where supported" (API level) and current state: no-op when isIgnoringBatteryOptimizations is already true, so already-unrestricted users are never
prompted.
Onboarding step shown after at least one account is added (so the "instant delivery"
pitch is concrete): explains and encourages, with a primary Take me there action and a
non-blocking Not now. Never blocks finishing onboarding.
On Take me there, deep-link to the system battery/allowlist screen
(ACTION_APPLICATION_DETAILS_SETTINGS, or ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
on return, re-check state and reflect it. No new manifest permission.
Don't nag: persist a "battery prompt handled/dismissed" flag in DataStore (mirror the
booleans in SettingsRepository / AppSettings) so onboarding asks at most once.
Advanced Settings entry (next to Push mail (IMAP IDLE)): show current battery status
(Unrestricted vs. optimized/restricted) and a button to (re)open the system screen — the
recovery path for users who skipped onboarding or are on OEMs with aggressive battery
managers. Consider surfacing it contextually when push is on but we're not allowlisted.
Onboarding + settings copy/strings; SPDX header on new files.
Unit-test the "should we prompt?" decision (supported API × not already unrestricted ×
not already dismissed). The intent launch is thin — cover it in E2E/manual.
Acceptance criteria
On a supported version where the app is not allowlisted, onboarding surfaces the opt-in exactly
once; Take me there opens the correct system screen; Not now continues onboarding and
does not auto-re-prompt.
If already unrestricted, or on a version without the feature, no prompt appears.
Advanced Settings always reflects the current battery state and can re-open the system screen.
With the app set to Unrestricted, IDLE push / periodic sync keep running in the background
across Doze windows (manual/E2E verification).
Open questions
The Restricted bucket (the real delivery-killer) is only reachable/detectable via the
App-info Battery screen, not the Doze allowlist API. Is deep-linking there with instructions in
scope, or is the Doze allowlist enough for our needs? (IDLE-as-foreground-service benefits mainly
from the allowlist.)
OEM battery managers (Samsung, Xiaomi/MIUI, OnePlus, Huawei — dontkillmyapp.com) ignore the
AOSP allowlist. Standard path + a help link now, per-OEM guidance later? (Suggest: out of scope
here.)
Placement in the flow — a dedicated final onboarding step, or folded into the #30
"add another? / first-account landing" juncture?
app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt + AppSettings (add the
"prompt handled" flag); ui/settings/SettingsScreen.kt (+ SettingsViewModel) for the Advanced
row; res/values/strings.xml
onboarding scaffold + session state from #26; ui/LibreMailApp.kt
Dependencies
Part of the Onboarding & account setup epic (#9); sequence after #26 (scaffold), near #30
(post-add landing). Ties into #17 (Google Play compliance) and #16 (F-Droid compliance) via the
battery-optimization policy. Amplifies the existing push (IDLE) feature — pairs with settings.pushIdle.
Part of #9.
## Context
Timely mail delivery depends on two mechanisms today, both of which Android's battery
management throttles:
- **Push:** `IdleService` — a `dataSync` foreground service holding an IMAP IDLE connection
per account for instant delivery (opt-in, `settings.pushIdle`, on by default; toggled under
**Advanced Settings**).
- **Polling:** `SyncScheduler.schedulePeriodicSync()` — a WorkManager periodic job at the
15-minute floor with a `CONNECTED` network constraint.
Doze, App Standby buckets, and especially the per-app **Restricted** battery setting defer
those WorkManager jobs and let the OS kill the foreground service, so mail arrives late or only
when the app is next opened. `IdlePushManager.start()` already swallows
`ForegroundServiceStartNotAllowedException` when a background start is blocked — moving the app
onto the battery allowlist reduces how often the OS tears IDLE down in the first place.
There's no battery handling in the app today (no `PowerManager` /
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` anywhere; the manifest declares none).
`MainActivity.NotificationPermissionEffect` is the pattern to mirror for a one-time,
first-run prompt.
**Two distinct Android surfaces, often conflated:**
1. **Doze allowlist** ("ignore battery optimizations"). Read with
`PowerManager.isIgnoringBatteryOptimizations(pkg)`. Changeable via the direct system dialog
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (needs the restricted
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission) **or** by deep-linking to the settings
screen with no special permission.
2. **Per-app Unrestricted / Optimized / Restricted** (App info → Battery, refined on Android
12+). **No API sets this** — we can only deep-link to app details and guide the user. Note
`isIgnoringBatteryOptimizations` returns `true` for **Unrestricted** and `false` for *both*
**Optimized** and **Restricted**, so it can't single out the harmful "Restricted" state.
**Chosen approach — guided deep-link (F-Droid-safe).** We explain the benefit, then send the
user to the system battery screen to flip the app to Unrestricted; we do **not** fire the
`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog. Google Play restricts that permission to an
approved set of use cases (rejection risk — see #17), whereas the deep-link path needs no extra
permission and is safe on both Play and F-Droid (#16), consistent with the project's F-Droid-first
posture. (A one-tap direct dialog could later be added behind an F-Droid-only flavor if we want it.)
## Scope
- [ ] Gate the feature on "where supported" (API level) **and** current state: no-op when
`isIgnoringBatteryOptimizations` is already `true`, so already-unrestricted users are never
prompted.
- [ ] Onboarding step shown **after at least one account is added** (so the "instant delivery"
pitch is concrete): explains and encourages, with a primary **Take me there** action and a
non-blocking **Not now**. Never blocks finishing onboarding.
- [ ] On **Take me there**, deep-link to the system battery/allowlist screen
(`ACTION_APPLICATION_DETAILS_SETTINGS`, or `ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`);
on return, re-check state and reflect it. **No new manifest permission.**
- [ ] Don't nag: persist a "battery prompt handled/dismissed" flag in DataStore (mirror the
booleans in `SettingsRepository` / `AppSettings`) so onboarding asks at most once.
- [ ] **Advanced Settings** entry (next to *Push mail (IMAP IDLE)*): show current battery status
(Unrestricted vs. optimized/restricted) and a button to (re)open the system screen — the
recovery path for users who skipped onboarding or are on OEMs with aggressive battery
managers. Consider surfacing it contextually when push is on but we're not allowlisted.
- [ ] Onboarding + settings copy/strings; SPDX header on new files.
- [ ] Unit-test the "should we prompt?" decision (supported API × not already unrestricted ×
not already dismissed). The intent launch is thin — cover it in E2E/manual.
## Acceptance criteria
- On a supported version where the app is not allowlisted, onboarding surfaces the opt-in exactly
once; **Take me there** opens the correct system screen; **Not now** continues onboarding and
does not auto-re-prompt.
- If already unrestricted, or on a version without the feature, no prompt appears.
- Advanced Settings always reflects the current battery state and can re-open the system screen.
- With the app set to Unrestricted, IDLE push / periodic sync keep running in the background
across Doze windows (manual/E2E verification).
## Open questions
- The **Restricted** bucket (the real delivery-killer) is only reachable/detectable via the
App-info Battery screen, not the Doze allowlist API. Is deep-linking there with instructions in
scope, or is the Doze allowlist enough for our needs? (IDLE-as-foreground-service benefits mainly
from the allowlist.)
- **OEM battery managers** (Samsung, Xiaomi/MIUI, OnePlus, Huawei — dontkillmyapp.com) ignore the
AOSP allowlist. Standard path + a help link now, per-OEM guidance later? (Suggest: out of scope
here.)
- **Placement** in the flow — a dedicated final onboarding step, or folded into the #30
"add another? / first-account landing" juncture?
## Relevant files
- `app/src/main/kotlin/org/libremail/push/IdleService.kt`, `push/IdlePushManager.kt` (what
benefits; already handles blocked background starts)
- `app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt` (periodic sync throttled under Doze)
- `app/src/main/kotlin/org/libremail/MainActivity.kt` (`NotificationPermissionEffect` — one-time
prompt pattern)
- `app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt` + `AppSettings` (add the
"prompt handled" flag); `ui/settings/SettingsScreen.kt` (+ `SettingsViewModel`) for the Advanced
row; `res/values/strings.xml`
- onboarding scaffold + session state from #26; `ui/LibreMailApp.kt`
## Dependencies
Part of the **Onboarding & account setup** epic (#9); sequence after #26 (scaffold), near #30
(post-add landing). Ties into #17 (Google Play compliance) and #16 (F-Droid compliance) via the
battery-optimization policy. Amplifies the existing push (IDLE) feature — pairs with
`settings.pushIdle`.
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.
Part of #9.
Context
Timely mail delivery depends on two mechanisms today, both of which Android's battery
management throttles:
IdleService— adataSyncforeground service holding an IMAP IDLE connectionper account for instant delivery (opt-in,
settings.pushIdle, on by default; toggled underAdvanced Settings).
SyncScheduler.schedulePeriodicSync()— a WorkManager periodic job at the15-minute floor with a
CONNECTEDnetwork constraint.Doze, App Standby buckets, and especially the per-app Restricted battery setting defer
those WorkManager jobs and let the OS kill the foreground service, so mail arrives late or only
when the app is next opened.
IdlePushManager.start()already swallowsForegroundServiceStartNotAllowedExceptionwhen a background start is blocked — moving the apponto the battery allowlist reduces how often the OS tears IDLE down in the first place.
There's no battery handling in the app today (no
PowerManager/REQUEST_IGNORE_BATTERY_OPTIMIZATIONSanywhere; the manifest declares none).MainActivity.NotificationPermissionEffectis the pattern to mirror for a one-time,first-run prompt.
Two distinct Android surfaces, often conflated:
PowerManager.isIgnoringBatteryOptimizations(pkg). Changeable via the direct system dialogACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS(needs the restrictedREQUEST_IGNORE_BATTERY_OPTIMIZATIONSpermission) or by deep-linking to the settingsscreen with no special permission.
12+). No API sets this — we can only deep-link to app details and guide the user. Note
isIgnoringBatteryOptimizationsreturnstruefor Unrestricted andfalsefor bothOptimized and Restricted, so it can't single out the harmful "Restricted" state.
Chosen approach — guided deep-link (F-Droid-safe). We explain the benefit, then send the
user to the system battery screen to flip the app to Unrestricted; we do not fire the
ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSdialog. Google Play restricts that permission to anapproved set of use cases (rejection risk — see #17), whereas the deep-link path needs no extra
permission and is safe on both Play and F-Droid (#16), consistent with the project's F-Droid-first
posture. (A one-tap direct dialog could later be added behind an F-Droid-only flavor if we want it.)
Scope
isIgnoringBatteryOptimizationsis alreadytrue, so already-unrestricted users are neverprompted.
pitch is concrete): explains and encourages, with a primary Take me there action and a
non-blocking Not now. Never blocks finishing onboarding.
(
ACTION_APPLICATION_DETAILS_SETTINGS, orACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);on return, re-check state and reflect it. No new manifest permission.
booleans in
SettingsRepository/AppSettings) so onboarding asks at most once.(Unrestricted vs. optimized/restricted) and a button to (re)open the system screen — the
recovery path for users who skipped onboarding or are on OEMs with aggressive battery
managers. Consider surfacing it contextually when push is on but we're not allowlisted.
not already dismissed). The intent launch is thin — cover it in E2E/manual.
Acceptance criteria
once; Take me there opens the correct system screen; Not now continues onboarding and
does not auto-re-prompt.
across Doze windows (manual/E2E verification).
Open questions
App-info Battery screen, not the Doze allowlist API. Is deep-linking there with instructions in
scope, or is the Doze allowlist enough for our needs? (IDLE-as-foreground-service benefits mainly
from the allowlist.)
AOSP allowlist. Standard path + a help link now, per-OEM guidance later? (Suggest: out of scope
here.)
"add another? / first-account landing" juncture?
Relevant files
app/src/main/kotlin/org/libremail/push/IdleService.kt,push/IdlePushManager.kt(whatbenefits; already handles blocked background starts)
app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt(periodic sync throttled under Doze)app/src/main/kotlin/org/libremail/MainActivity.kt(NotificationPermissionEffect— one-timeprompt pattern)
app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt+AppSettings(add the"prompt handled" flag);
ui/settings/SettingsScreen.kt(+SettingsViewModel) for the Advancedrow;
res/values/strings.xmlui/LibreMailApp.ktDependencies
Part of the Onboarding & account setup epic (#9); sequence after #26 (scaffold), near #30
(post-add landing). Ties into #17 (Google Play compliance) and #16 (F-Droid compliance) via the
battery-optimization policy. Amplifies the existing push (IDLE) feature — pairs with
settings.pushIdle.