Focus Owner hides every package absent from the allowlist, so a newly installed lyricanki was installed=true but hidden=true: absent from the launcher and from `pm list packages`, and `am start` reported the activity did not exist even though dumpsys listed its MAIN/LAUNCHER filter. Nothing about the app itself was wrong. policy.json is regenerated from config.sh by python_pkg.focus_policy, never hand-edited. Also drops com.android.launcher3 and com.android.camera2 from SYSTEM_NEVER_DISABLE. That list is prefix-matched and both are strict supersets of entries already in it, so coverage is byte-identical; this keeps config.sh under the 250-line cap without weakening the policy. com.android.settings stays: its only shorter match is com.android.se, an accidental substring rather than intent. Verified on the phone: signer matched so the Device Owner upgraded in place, dpm still reports DeviceOwner, lyricanki flipped to hidden=false, and workout_app + the dialer are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAEChhK3sWGxd6ucsUQmcT |
||
|---|---|---|
| .. | ||
| android | ||
| assets | ||
| docs | ||
| lib | ||
| scripts | ||
| test | ||
| .gitignore | ||
| .metadata | ||
| analysis_options.yaml | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
focus_owner
Device Owner app that enforces focus mode on an unrooted Pixel 6a
(23181JEGR08034). It hides apps by GPS distance from home, plus a 23:00–05:00
night curfew, and keeps YouTube hidden everywhere.
Live and enforcing. It is provisioned as device owner, pins RethinkDNS as an always-on VPN with lockdown, and blocks its own uninstall while enforcing.
How a decision is made
One pass runs every 15 minutes (and at each curfew boundary), from
EnforcementService → EnforcementRunner.decide() → EnforcementDecision.evaluate().
There are five outcomes, and only the first two look different from the outside — which is exactly what made this hard to debug before the status screen existed:
| Reason | What it does |
|---|---|
AWAY |
Hides only always_blocked_packages; everything else is available. |
AT_HOME |
Full day allowlist sweep. |
CURFEW |
Full sweep against the shorter night allowlist. |
LOCATION_UNKNOWN |
Identical sweep to AT_HOME — fail-closed, so losing GPS is not an off switch. |
WORKOUT |
As at home, plus the workout exemptions. |
Plus a sixth state that is not a reason: no home configured, which makes the
fence answer "inside" everywhere and so enforces everywhere. The status screen
renders that case explicitly rather than letting it read as AT_HOME.
always_blocked_packages (YouTube, YouTube Music, Chrome) are exempt from every
branch including AWAY and WORKOUT, so leaving the house cannot restore them.
Diagnosing it
Use the in-app screen, not adb. Neither adb route works here:
run-asreturnspackage not debuggable— device owner requires a release build, and making it debuggable would let anyone with adb edit this app's state, which is the bypass the whole design exists to close.- logcat rotates. Measured 2026-08-14:
adb logcat -d -s FocusOwner:Vcame back empty whiledumpsys alarmshowed 82 alarms had fired.
So the durable record is filesDir/enforcement_log.jsonl, written by
EnforcementLog and read back over the platform channel. The main screen shows
the latest pass — reason, distance vs the fence, fix age/provider/accuracy,
curfew state, and every hidden app with the rule that hid it — and Debug log
shows the history. It never records coordinates: distance in metres is enough to
debug a geofence, and this is a home address.
Useful fields when something looks wrong:
fix.outcome—CACHED_FRESH/ACTIVE_OK/TIMEOUT/NO_PERMISSION/NO_PROVIDER. Distinguishes "no fix at the office" from "fuzzed fix at home".fix.accuracy_m— tens of metres means precise location is live. Hundreds or thousands means the app is being fed fuzzed coarse location and the 150 m fence cannot be trusted.failure— set on passes that decided nothing (not provisioned, unreadable policy), which otherwise leave no trace at all.
Location
The geofence needs ACCESS_FINE_LOCATION; coarse is fuzzed to a ~1–2 km grid on
Android 12+, against a 150 m radius. The app self-grants fine, coarse and
background location every pass via setPermissionGrantState (device owner needs
no prompt), and calls setLocationEnabled(true).
Each pass actively requests a fix rather than reading whatever another app
cached, bounded by ACQUIRE_TIMEOUT_MS (20 s) and run inside a
location-typed foreground service — background location delivery is throttled
too hard otherwise. A cached fix younger than FRESH_WINDOW_MS (30 min) is used
as-is; anything older is treated as no fix and fails closed. That window is
deliberately longer than the 15-minute cadence: a shorter one would mark the
newest available cache stale on every pass of an idle phone and block everything.
Policy
Generated from phone_focus_mode/config.sh, never hand-edited:
cd ~/testsAndMisc
python3 -m python_pkg.focus_policy \
--config phone_focus_mode/config.sh \
--output focus_owner/assets/policy.json --redact-home
--redact-home is required — the committed asset must keep latitude/
longitude null. Real coordinates live only in app-private storage, written by
"Set home to current location" in the app.
Allowlists are exact-match, plus allowed_prefixes / night_allowed_prefixes
for apps that ship as a package family (Tachiyomi installs every source as its
own apk). Prefixes match whole labels, so eu.kanade.tachiyomi covers
eu.kanade.tachiyomi.extension.all.mangadex but not eu.kanade.tachiyomisomething.
Sweepable is not the same as hideable. blockable_system_packages decides
which FLAG_SYSTEM packages are eligible for a decision; isAllowed is what
then protects them. Removing an allowed system package from that list does not
protect it, it freezes it in whatever state it was last left in — it lands
in neither packagesToHide nor packagesToShow. Play Store is the live example.
Build and verify
cd ~/testsAndMisc/focus_owner
flutter analyze && flutter test
JAVA_HOME=/usr/lib/jvm/java-21-openjdk (cd android && ./gradlew :app:testDebugUnitTest)
JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release
adb -s 23181JEGR08034 install -r build/app/outputs/flutter-apk/app-release.apk
adb -s 23181JEGR08034 shell am start -n com.kuhy.focus_owner/.MainActivity
JAVA_HOME is required: the system default JDK 26 breaks the Android Gradle
plugin's jlink transform.
Release build only — device owner refuses a debuggable one. Then tap Run
enforcement now and read the status card; EnforcementService is not
exported, so it cannot be started from adb.
Never
adb uninstall,pm clear, or wipe this app's data. Uninstalling a device owner strands ownership with no holder and a factory reset becomes the only exit.install -ris safe and preserves the home location.
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. So releaseDeviceOwner() is implemented,
unit-tested, and reachable from the main screen with no PC attached.
Accounts now exist on the device, so dpm set-device-owner would refuse to run
again: releasing is a one-way door until a wipe. The confirmation dialog says so
in those terms. See
~/.claude/projects/-home-kuhy-testsAndMisc/memory/device-owner-not-root-banking-works.md.
Known gap
The network layer does not filter youtube.com. private_dns_host is empty
(the router does not forward 853, and under VPN lockdown app DNS goes to the
VPN's own resolver anyway), and RethinkDNS's blocklists can be switched off from
inside that app. So a browser reaches YouTube on the web when away from home.
At home and during curfew every browser is hidden, which is what closes it there.