feat(onboarding): deep-link battery step nearer the per-app background-activity screen (best-effort) #173

Merged
JMR-dev merged 8 commits from feat-150-battery-deeplink into main 2026-07-03 00:55:54 +00:00
JMR-dev commented 2026-07-02 22:20:12 +00:00 (Migrated from github.com)

Summary

Issue #150 asked for the onboarding "Take me there" battery step to land as close as
possible to the per-app Unrestricted / Optimized / Restricted screen instead of the
generic app-info page — without using the restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
permission (Play rejection risk, see #17).

Spike findings

I checked the actual AOSP Settings source (frameworks/base Settings.java), not just the
reference docs, for every public, non-@hide Settings.ACTION_* constant mentioning battery
or power. Conclusion: no public, non-hidden action opens the exact
Unrestricted/Optimized/Restricted screen for a specific package.

  • Settings.ACTION_VIEW_ADVANCED_POWER_USAGE_DETAIL would — it's package-scoped
    (package: data URI) and looks like exactly the per-app battery-usage screen — but it's
    @hide in the platform source, i.e. not part of the public SDK. Using its literal action
    string would mean depending on a private, unversioned implementation detail — the exact
    fragility this class already avoids by not using the restricted permission. Ruled out.
  • Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS is public and needs no permission,
    but it is not package-scoped: it opens the system-wide "Battery Optimization" app list
    (default filter hides already-optimized apps, so the user has to switch to "All apps" to
    even find LibreMail), and tapping an app there shows a legacy two-state
    Optimize/"Don't optimize" dialog that predates the "Restricted" option. That's a worse
    landing than app-details for one specific, already-known app, so it's used only as a
    fallback, not the primary target.
  • Settings.ACTION_APPLICATION_DETAILS_SETTINGS (today's intent), scoped to our package,
    remains the closest safe, public, package-scoped option: on stock Android/Pixel/AOSP it
    lands one tap ("Battery") away from the target screen.

What changed

BatteryOptimizationManager.settingsIntent() now tries a small, verified fallback chain
instead of unconditionally returning one intent:

  1. Settings.ACTION_APPLICATION_DETAILS_SETTINGS scoped to our package (primary — unchanged
    destination from before this change).
  2. Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS (fallback, only reached if #1
    somehow doesn't resolve on a given device).

Each candidate is checked with Intent.resolveActivity(); the first that resolves wins, and
if somehow neither does, the last candidate is still returned so the caller never gets a
dead-end intent. On Pixel/AOSP and every real device I'm aware of, #1 always resolves, so
the landing screen is unchanged from before — the honest outcome of this spike is that
today's app-details intent was already the closest safe option available; see the class KDoc
in BatteryOptimizationManager.kt for the full investigation notes and OEM caveats (OEM skins
that rename/relocate/drop the "Battery" entry from app-details have no public replacement
either, short of hardcoding fragile OEM component names, which this deliberately avoids).

The selection/fallback logic is extracted into a small Android-free helper
(firstResolvableOrElseLast) so it's directly unit-tested without touching real Android intent
resolution; a new instrumented test checks the real candidate intents/order against a real
PackageManager.

No restricted permission or hidden/private API is used anywhere in this change.

Test plan

  • assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt +
    compileDebugAndroidTestKotlin all green locally (JDK 21).
  • New JVM unit test (BatteryOptimizationManagerTest, 6 cases) covers the fallback-selection
    logic: first-resolving-candidate wins, falls back to the last candidate rather than
    dead-ending, and rejects an empty candidate list.
  • New instrumented test (BatteryOptimizationManagerIntentTest) checks the real candidate
    intents/order/package-scoping against a real PackageManager.
  • Existing instrumented BatteryOptimizationStepTest (Espresso-Intents) needed no changes
    and continues to assert the "Take me there" button launches ACTION_APPLICATION_DETAILS_SETTINGS
    scoped to our package — still true, since that's the primary candidate and always resolves
    on a real/emulated device.
  • Emulator E2E left to CI.

Closes #150

🤖 Generated with Claude Code

## Summary Issue #150 asked for the onboarding "Take me there" battery step to land as close as possible to the per-app **Unrestricted / Optimized / Restricted** screen instead of the generic app-info page — without using the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission (Play rejection risk, see #17). ### Spike findings I checked the actual AOSP `Settings` source (`frameworks/base` `Settings.java`), not just the reference docs, for every public, non-`@hide` `Settings.ACTION_*` constant mentioning battery or power. Conclusion: **no public, non-hidden action opens the exact Unrestricted/Optimized/Restricted screen for a specific package.** - `Settings.ACTION_VIEW_ADVANCED_POWER_USAGE_DETAIL` *would* — it's package-scoped (`package:` data URI) and looks like exactly the per-app battery-usage screen — but it's `@hide` in the platform source, i.e. not part of the public SDK. Using its literal action string would mean depending on a private, unversioned implementation detail — the exact fragility this class already avoids by not using the restricted permission. Ruled out. - `Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS` is public and needs no permission, but it is **not** package-scoped: it opens the system-wide "Battery Optimization" app list (default filter hides already-optimized apps, so the user has to switch to "All apps" to even find LibreMail), and tapping an app there shows a legacy two-state Optimize/"Don't optimize" dialog that predates the "Restricted" option. That's a worse landing than app-details for one specific, already-known app, so it's used only as a fallback, not the primary target. - `Settings.ACTION_APPLICATION_DETAILS_SETTINGS` (today's intent), scoped to our package, remains the closest safe, public, package-scoped option: on stock Android/Pixel/AOSP it lands **one tap ("Battery") away** from the target screen. ### What changed `BatteryOptimizationManager.settingsIntent()` now tries a small, verified fallback chain instead of unconditionally returning one intent: 1. **`Settings.ACTION_APPLICATION_DETAILS_SETTINGS`** scoped to our package (primary — unchanged destination from before this change). 2. **`Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`** (fallback, only reached if #1 somehow doesn't resolve on a given device). Each candidate is checked with `Intent.resolveActivity()`; the first that resolves wins, and if somehow neither does, the last candidate is still returned so the caller never gets a dead-end intent. On Pixel/AOSP and every real device I'm aware of, #1 always resolves, so **the landing screen is unchanged from before** — the honest outcome of this spike is that today's app-details intent was already the closest safe option available; see the class KDoc in `BatteryOptimizationManager.kt` for the full investigation notes and OEM caveats (OEM skins that rename/relocate/drop the "Battery" entry from app-details have no public replacement either, short of hardcoding fragile OEM component names, which this deliberately avoids). The selection/fallback logic is extracted into a small Android-free helper (`firstResolvableOrElseLast`) so it's directly unit-tested without touching real Android intent resolution; a new instrumented test checks the real candidate intents/order against a real `PackageManager`. No restricted permission or hidden/private API is used anywhere in this change. ## Test plan - [x] `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` + `compileDebugAndroidTestKotlin` all green locally (JDK 21). - [x] New JVM unit test (`BatteryOptimizationManagerTest`, 6 cases) covers the fallback-selection logic: first-resolving-candidate wins, falls back to the last candidate rather than dead-ending, and rejects an empty candidate list. - [x] New instrumented test (`BatteryOptimizationManagerIntentTest`) checks the real candidate intents/order/package-scoping against a real `PackageManager`. - [x] Existing instrumented `BatteryOptimizationStepTest` (Espresso-Intents) needed no changes and continues to assert the "Take me there" button launches `ACTION_APPLICATION_DETAILS_SETTINGS` scoped to our package — still true, since that's the primary candidate and always resolves on a real/emulated device. - [ ] Emulator E2E left to CI. Closes #150 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.