testsAndMisc/focus_owner
Krzysztof kuhy Rudnicki cc804dd311 Run enforcement from exact alarms, and re-arm it before first unlock
The decision layer needed a runtime. A polling loop was the obvious design and
the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00
boundary leaves the curfew allowlist applied into the morning -- the user finds
their banking app missing with no obvious cause. The transitions are known
ahead of time, so they get exact alarms. The service wakes, applies, schedules
the next run and stops rather than sitting resident.

BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the
latter does not arrive until first unlock. That is not theoretical: this phone
sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with
no network and no app able to start. Relying on BOOT_COMPLETED alone freezes
enforcement state across a reboot -- safe for hiding, not for unhiding, which is
the direction that strands you without a dialer. The schedule is re-armed
before unlock; the pass itself waits until storage is readable.

The first on-device run logged "exact alarms not permitted", and dumpsys showed
what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute
fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and
denied by default on Android 16; USE_EXACT_ALARM is the permission for apps
whose function is acting at specific times, and is granted at install. After
the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to
maxWhenElapsed.

The service reports "not DO/PO - no enforcement applied" and does nothing when
unprovisioned, rather than failing quietly, and a failure inside a pass cannot
stop the next run being scheduled -- an enforcement bug should degrade one tick,
not end the chain.

Not yet wired: the service logs a tick rather than calling setApplicationHidden
with the decision. Both halves are tested; joining them is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:50:58 +02:00
..
android Run enforcement from exact alarms, and re-arm it before first unlock 2026-08-09 21:50:58 +02:00
assets Serve the blocklist over DoT and hand the policy to the app as an asset 2026-08-05 09:36:11 +02:00
lib Run enforcement from exact alarms, and re-arm it before first unlock 2026-08-09 21:50:58 +02:00
scripts Provision home coordinates out of git; layer the workout signal; drop the test hook 2026-08-09 21:42:20 +02:00
test Provision home coordinates out of git; layer the workout signal; drop the test hook 2026-08-09 21:42:20 +02:00
.gitignore Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00
.metadata Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00
analysis_options.yaml Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00
pubspec.lock Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00
pubspec.yaml Exempt the generated policy asset from prettier 2026-08-05 09:40:44 +02:00
README.md Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00

focus_owner

Device Owner companion app for unrooted focus mode. Currently inert: it can be provisioned as device owner, but it applies no restrictions.

Why the escape hatch ships first

dpm remove-active-admin does not work on a device owner. The only exit that avoids a factory reset is DevicePolicyManager.clearDeviceOwnerApp(), callable solely by this package on itself. An app that locks the device down before proving it can let go leaves a soft-brick recoverable only through fastboot — painful on a locked bootloader, and impossible to reach if DISALLOW_FACTORY_RESET is ever set.

So: DevicePolicyBridge.releaseDeviceOwner() is implemented, unit-tested, and reachable from the app's main screen with no PC attached. Enforcement is added only after that path has been exercised on a provisioned device.

Provisioning (NOT done yet — requires a factory reset)

dpm set-device-owner refuses to run while any account exists on the device. The Pixel 6a has 6, so provisioning means a wipe. See ~/.claude/projects/-home-kuhy-testsAndMisc/memory/device-owner-not-root-banking-works.md for what does and does not survive that, and for the Google Wallet open risk.

Once wiped, before adding any account:

adb install -r build/app/outputs/flutter-apk/app-debug.apk
adb shell dpm set-device-owner com.kuhy.focus_owner/.FocusDeviceAdminReceiver

Then immediately verify tap-to-pay and banking apps, while the release button is still reachable.

Build and verify

cd ~/testsAndMisc/focus_owner
flutter analyze && flutter test
flutter build apk --debug
adb -s 23181JEGR08034 install -r build/app/outputs/flutter-apk/app-debug.apk
adb -s 23181JEGR08034 shell am start -n com.kuhy.focus_owner/.MainActivity

The status screen reads live state over the platform channel; "Android SDK 36" confirms the Kotlin bridge is working.

Still to do

  • Consume the policy JSON from python_pkg/focus_policy (export.py).
  • Add enforcement (setApplicationHidden, addUserRestriction, setAlwaysOnVpnPackage) — only after the release path is verified on-device.
  • Verify setApplicationHidden survives a reboot. pm suspend and pm disable-user both do NOT; do not assume the DPM equivalent behaves the same until measured.