mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 12:07:52 +01:00
The enforcer failed in both directions: nothing blocked at home, and everything blocked at the office. The comparison was never inverted -- `metres <= threshold` was always right. Three separate causes: Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a ~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed outside it, read as AWAY, and restored every app the geofence exists to hide. FINE is now declared and self-granted by the device owner, along with coarse and background, re-asserted every pass so a reinstall cannot silently drop it. There were three states but two behaviours. LOCATION_UNKNOWN applies the identical sweep to AT_HOME -- correct fail-closed design, indistinguishable from outside. And nothing ever requested a fix: the pass read whatever another app happened to cache, so at the office with no navigation running the cache was empty. Passes now acquire a fix inside a location-typed foreground service, bounded at 20 s, and treat anything older than 30 min as no fix. That window is deliberately longer than the 15-minute cadence; a shorter one marks the newest cache stale on every pass of an idle phone and blocks everything. None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty while dumpsys showed 82 alarms had fired, and `run-as` is refused on the release build device owner requires. So the pass now writes a ring-buffered JSONL record read back in-app: reason, distance vs the fence, fix age/provider/accuracy, curfew state, and every hidden app with the rule that hid it. Coordinates are never recorded -- a distance is enough to debug a geofence, and this is a home address. Also fixed along the way: - Hysteresis keyed off `isEnforcing`, which is true under AWAY too because YouTube stays hidden, so the threshold was permanently radius+hysteresis. It now keys off whether the previous pass was inside the fence. - The scheduler's curfew-boundary alarms were documented but never scheduled; both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late. - Installing over the app cancelled its pending alarms, so `adb install -r` silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED now re-arms it, verified on device without launching the app. - A wrong-typed log record threw instead of being skipped, and the exception escaped far enough to leave the page stuck busy -- which disables the release-device-owner button, the one control that must work when everything else has gone wrong. - `lib/enforcement.dart` was a second decision layer with no always-blocked concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed. It would have made the new screen report YouTube visible while it is hidden. Deleted; the UI renders the Kotlin record rather than re-deciding. - Anchoring home accepted a stale or unknown-precision fix. A wrong home offsets the fence everywhere and forever, which is what produced a 766 m reading while standing at home. Policy: Play Store is allowed by day (not during curfew) so infakt can be installed and updated; the sweep is default-deny for third-party packages, so anything installed from Play is hidden on the next at-home pass. Tachiyomi's per-source extension apks are covered by a new prefix allowlist rather than an exact list that goes stale whenever an extension is installed. `export.py` stopped subtracting the allowlist from `blockable_system_packages`. Sweepable is not hideable: that list decides which system packages are eligible for a decision, and `isAllowed` is what protects them. Removing an allowed system package from the sweep freezes it in its last state -- it would have stranded Play hidden permanently the moment it was allowlisted. Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a 0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from `pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi packages present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
143 lines
7.1 KiB
Markdown
143 lines
7.1 KiB
Markdown
# 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-as` returns `package 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:V` came back
|
||
**empty** while `dumpsys alarm` showed 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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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 -r` is 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.
|