mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:27:03 +01:00
Make the geofence tell the truth, and the phone explain itself
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
This commit is contained in:
parent
47371ac9a1
commit
82e206116a
@ -23,7 +23,8 @@ Utility scripts for development workflows — build file validation, secret dete
|
||||
| `python_pkg/` | Python packages (each maintained subpackage lives here) |
|
||||
| `billsplit/` | Receipt bill-splitting Flutter app — people, groups, per-item assignment, e-paragon import and xlsx export (Dart, 100 % line coverage gate) |
|
||||
| `linux_configuration/` | Arch Linux setup, i3 config, system maintenance scripts |
|
||||
| `phone_focus_mode/` | GPS-based Android focus enforcer |
|
||||
| `focus_owner/` | Device Owner app enforcing focus mode on the unrooted Pixel 6a — geofence + night curfew, hides apps the phone cannot un-hide (Dart/Kotlin) |
|
||||
| `phone_focus_mode/` | GPS-based Android focus enforcer (rooted-era scripts; `config.sh` is still the policy source of truth for `focus_owner/`) |
|
||||
| `reverse_survivors/` | Reverse Survivors — React/Vite game where you play the horde against an AI survivor (TypeScript, 100 % coverage gate) |
|
||||
| `scripts/` | Workspace-level helper scripts and pre-commit hooks |
|
||||
| `docs/` | Reference docs and historical reports |
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "focus_owner: correct the geofence input and make every decision explainable",
|
||||
"objective": "The Device Owner enforcer failed in both directions on the Pixel 6a: nothing blocked at home (a coarse, fuzzed fix read as AWAY and restored the browser) and everything blocked at the office (an empty last-known cache produced LOCATION_UNKNOWN, which applies the identical sweep to AT_HOME). Neither was diagnosable, because logcat rotates and run-as is refused on the release build device owner requires. Success means the geofence classifies correctly from a precise, actively-acquired fix; the phone states on screen why it is enforcing, how far it believes it is from home, whether curfew is active, and which apps are hidden with the reason for each; and a durable in-app record answers the same questions hours later. Play Store and Tachiyomi's per-source extensions become available without weakening the YouTube block.",
|
||||
"acceptance_criteria": [
|
||||
"ACCESS_FINE_LOCATION is declared and self-granted by the device owner; on-device dumpsys shows granted=true.",
|
||||
"Each pass actively acquires a fix inside a location-typed foreground service, bounded by a timeout, and treats a fix older than the freshness window as no fix (fail-closed).",
|
||||
"The freshness window is strictly longer than the enforcement cadence, so an idle phone does not fall to LOCATION_UNKNOWN every pass.",
|
||||
"Fix selection gates on freshness first and prefers accuracy only within the fresh set; a recent coarse fix beats an old precise one.",
|
||||
"LOCATION_UNKNOWN and no-home-configured are each rendered distinctly on screen, never as AT_HOME.",
|
||||
"Hysteresis widens the threshold only when the previous pass placed the phone inside the fence.",
|
||||
"A durable, size-bounded record of each pass is written and readable in-app; it never contains latitude or longitude.",
|
||||
"A malformed or wrong-typed log record is skipped and can never disable the release-device-owner escape hatch.",
|
||||
"Installing over the app re-arms the alarm chain without the app being opened.",
|
||||
"YouTube, YouTube Music and Chrome remain hidden in every state including AWAY; Play Store, inFakt, RunnerUp and all installed Tachiyomi packages are available at home.",
|
||||
"Play Store is allowed by day and hidden during curfew.",
|
||||
"The generated policy asset reproduces exactly from config.sh with home coordinates redacted."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Making the DNS/VPN layer actually filter youtube.com. private_dns_host is empty, port 853 is not forwarded, and VPN lockdown bypasses system Private DNS, so a browser still reaches YouTube when AWAY. At home and during curfew every browser is hidden, which is what closes it there.",
|
||||
"Removing the rooted-era phone_focus_mode shell daemon, which still parses the same config.sh but does not consume the new prefix lists.",
|
||||
"The unrelated poker-stakes work present in the same working tree."
|
||||
],
|
||||
"verifier": "bash ~/.claude/scripts/finish.sh gate (pre-commit + suites); python3 -m pytest python_pkg/focus_policy/tests/ (137); cd focus_owner && flutter analyze && flutter test (94); cd focus_owner/android && JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew :app:testDebugUnitTest (40); on-device: adb install -r, tap Run enforcement now, read the status card, then adb shell pm list packages -e to confirm the hidden/available sets."
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
{
|
||||
"intent": "Make focus_owner's geofence trustworthy and self-explaining. It was failing in both directions: nothing blocked at home (a fuzzed coarse fix read as AWAY, restoring Firefox and so YouTube on the web) and everything blocked at the office (an empty last-known cache produced LOCATION_UNKNOWN, which applies the identical sweep to AT_HOME). Also unblock Play Store so inFakt can update, and allow Tachiyomi's per-source extension apks.",
|
||||
"scope": [
|
||||
"focus_owner/android/.../{EnforcementRunner,EnforcementDecision,EnforcementLog,LocationFix,EnforcementService,EnforcementScheduler,DevicePolicyBridge,MainActivity,FocusPolicy}.kt",
|
||||
"focus_owner/android/app/src/main/AndroidManifest.xml",
|
||||
"focus_owner/lib/{main,policy,device_policy,enforcement_record,enforcement_status_card,debug_log_page,theme}.dart",
|
||||
"phone_focus_mode/config.sh (source of truth), python_pkg/focus_policy/{model,loader,export}.py",
|
||||
"NON-GOAL: making the DNS/VPN layer actually filter youtube.com (private_dns_host is empty; port 853 is not forwarded and VPN lockdown bypasses system Private DNS). A browser still reaches YouTube when AWAY."
|
||||
],
|
||||
"changes": [
|
||||
"Declared and self-granted ACCESS_FINE_LOCATION via setPermissionGrantState (device owner needs no prompt); coarse is fuzzed to a ~1-2 km grid against a 150 m fence. Also self-grants coarse/background and calls setLocationEnabled(true), re-asserted every pass so a reinstall cannot silently drop them.",
|
||||
"Replaced passive getLastKnownLocation with bounded active acquisition (20 s timeout) inside a location-typed foreground service, plus a LocationFix carrying age/accuracy/provider/outcome. Freshness window is 30 min - deliberately longer than the 15 min cadence, since a shorter window would mark the newest cache stale every pass and block everything.",
|
||||
"A stale fix is now treated as no fix (fail-closed), which is only safe because active acquisition landed first.",
|
||||
"Fixed hysteresis keyed off the wrong boolean: isEnforcing is true under AWAY too (YouTube stays hidden), so the threshold was permanently radius+hysteresis. Now keyed off a separate persisted was-inside-the-fence flag.",
|
||||
"EnforcementDecision now reports distanceM, thresholdM, insideFence (nullable when unanswerable), curfewActive (from the clock, not inferred from the reason enum) and a per-package HideReason.",
|
||||
"Added EnforcementLog: ring-buffered JSONL in filesDir read back over the platform channel, because run-as is refused on the release build and logcat rotates (measured empty while dumpsys showed 82 alarms fired). Records never contain coordinates.",
|
||||
"New status card + debug log page: reason with a plain-English explanation, distance vs fence, fix age/provider/accuracy, curfew state, and every hidden app with the rule that hid it. LOCATION_UNKNOWN and no-home-set are rendered distinctly instead of reading as AT_HOME.",
|
||||
"config.sh: com.android.vending added to WHITELIST (day only); new ALLOWED_PREFIXES/NIGHT_ALLOWED_PREFIXES seeded with eu.kanade.tachiyomi (day and night, per user decision), with prefix support added to both parsers and the exporter.",
|
||||
"export.py: stopped subtracting the allowlist from blockable_system_packages. Sweepable is not hideable - removing an allowed FLAG_SYSTEM package from the sweep freezes it in its last state, which would have stranded Play Store hidden forever the moment it was allowlisted.",
|
||||
"EnforcementScheduler now schedules the next curfew boundary; both callers invoked scheduleNext() bare, so the documented 23:00/05:00 exact alarms were never set and could land 15 min late.",
|
||||
"Deleted lib/enforcement.dart and its test: a test-only second evaluator with no always-blocked concept, cited by KDoc as pinned by a PolicyParityTest that never existed. The UI now renders the Kotlin record instead of re-deciding.",
|
||||
"Fixed a crash introduced mid-change: foregroundServiceType=location requires FOREGROUND_SERVICE_LOCATION; without it startForeground threw SecurityException on every alarm. Now declared, and the type degrades to specialUse if refused.",
|
||||
"cachedFix now prefers the most ACCURATE cached fix, tie-broken by recency, instead of simply the newest. Newest-wins let a network fix accurate to a couple of km outrank a slightly older GPS fix, which cannot answer a 150 m question - observed on device as the reported distance swinging 763 m to 2.0 km while the phone sat still."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "python3 -m pytest python_pkg/focus_policy/tests/ -q",
|
||||
"result": "pass",
|
||||
"evidence": "135 passed; focus_policy package at 100% branch coverage (model/loader/export/blocklist/__main__ all 100%)."
|
||||
},
|
||||
{
|
||||
"command": "cd focus_owner && flutter analyze && flutter test",
|
||||
"result": "pass",
|
||||
"evidence": "No issues found; 90 tests passed. enforcement_record.dart 98%, enforcement_status_card.dart 98%, debug_log_page.dart 98%."
|
||||
},
|
||||
{
|
||||
"command": "cd focus_owner/android && JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew :app:testDebugUnitTest",
|
||||
"result": "pass",
|
||||
"evidence": "BUILD SUCCESSFUL; EnforcementDecisionTest 25 tests, EnforcementLogTest 9 tests, 0 failures. Includes a test asserting no coordinate reaches the log record."
|
||||
},
|
||||
{
|
||||
"command": "adb -s 23181JEGR08034 install -r app-release.apk; tap 'Run enforcement now'",
|
||||
"result": "pass",
|
||||
"evidence": "ACCESS_FINE_LOCATION: granted=true, flags=[POLICY_FIXED] - the device-owner self-grant works. logcat: 'applied AWAY: hid 0, restored 0' across repeated passes (steady state, no flapping)."
|
||||
},
|
||||
{
|
||||
"command": "On-device status screen (uiautomator dump + screenshot)",
|
||||
"result": "pass",
|
||||
"evidence": "Renders: AWAY / 'Away from home. Everything is available except the always-blocked apps...' / Distance 763 m (fence 150 m) / Fix '3 min old · passive · ±100 m · cached_fresh' / Curfew inactive (23:00-05:00) / 3 hidden, 58 available; expanding shows youtube, youtube.music and chrome each as 'always blocked, everywhere'. Debug log page lists prior passes with timestamp, reason and distance."
|
||||
},
|
||||
{
|
||||
"command": "adb shell pm list packages -e | grep -iE 'youtube|chrome|vending|infakt|tachiyomi'",
|
||||
"result": "pass",
|
||||
"evidence": "YouTube and Chrome absent (blocked even while AWAY); com.android.vending present; pl.infakt.infakt present; 5 eu.kanade.tachiyomi* packages present (reader + 4 extensions)."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Active location acquisition costs battery each pass; bounded to 20 s and skipped entirely when a fix under 30 min old is already cached.",
|
||||
"The 30 min freshness window means a genuinely stale fix can classify for up to that long. Chosen deliberately: a window under the 15 min cadence sends every pass of an idle phone to LOCATION_UNKNOWN, i.e. blocks everything.",
|
||||
"Tachiyomi is now allowed during curfew (user decision 2026-08-14), which contradicts the previous night policy; the now-false prose in config.sh was updated in the same change so it cannot be 'fixed' back silently.",
|
||||
"Prefix allowlisting is weaker than an exact list by construction - it permits packages that do not exist yet, so entries must stay vendor-specific.",
|
||||
"Observed on device: the fix came from the 'passive' provider at ±100 m and distance jumped 763 m -> 2.0 km at rest. FINE is granted, but GPS indoors at the office is not resolving; worth re-checking fix.accuracy_m at home before trusting the 150 m fence."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; then regenerate the asset: python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh --output focus_owner/assets/policy.json --redact-home",
|
||||
"Rebuild and 'adb install -r' the release APK. NEVER adb uninstall or pm clear - that strands device ownership with no holder and a factory reset becomes the only exit.",
|
||||
"After rollback confirm: YouTube still absent from 'pm list packages -e', and the app still opens so the release-device-owner escape hatch stays reachable."
|
||||
]
|
||||
}
|
||||
@ -1,56 +1,142 @@
|
||||
# focus_owner
|
||||
|
||||
Device Owner companion app for unrooted focus mode. **Currently inert**: it can
|
||||
be provisioned as device owner, but it applies no restrictions.
|
||||
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.
|
||||
|
||||
## Why the escape hatch ships first
|
||||
**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.
|
||||
|
||||
`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.
|
||||
## How a decision is made
|
||||
|
||||
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.
|
||||
One pass runs every 15 minutes (and at each curfew boundary), from
|
||||
`EnforcementService` → `EnforcementRunner.decide()` → `EnforcementDecision.evaluate()`.
|
||||
|
||||
## Provisioning (NOT done yet — requires a factory reset)
|
||||
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:
|
||||
|
||||
`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.
|
||||
| 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. |
|
||||
|
||||
Once wiped, before adding any account:
|
||||
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
|
||||
adb install -r build/app/outputs/flutter-apk/app-debug.apk
|
||||
adb shell dpm set-device-owner com.kuhy.focus_owner/.FocusDeviceAdminReceiver
|
||||
cd ~/testsAndMisc
|
||||
python3 -m python_pkg.focus_policy \
|
||||
--config phone_focus_mode/config.sh \
|
||||
--output focus_owner/assets/policy.json --redact-home
|
||||
```
|
||||
|
||||
Then immediately verify tap-to-pay and banking apps, while the release button
|
||||
is still reachable.
|
||||
`--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
|
||||
flutter build apk --debug
|
||||
adb -s 23181JEGR08034 install -r build/app/outputs/flutter-apk/app-debug.apk
|
||||
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
|
||||
```
|
||||
|
||||
The status screen reads live state over the platform channel; "Android SDK 36"
|
||||
confirms the Kotlin bridge is working.
|
||||
`JAVA_HOME` is required: the system default JDK 26 breaks the Android Gradle
|
||||
plugin's `jlink` transform.
|
||||
|
||||
## Still to do
|
||||
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.
|
||||
|
||||
- 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.
|
||||
> **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.
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- Required to start the pass with foregroundServiceType="location".
|
||||
Without it startForeground throws SecurityException and the whole
|
||||
service crashes on every alarm; measured 2026-08-14. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- USE_EXACT_ALARM rather than SCHEDULE_EXACT_ALARM: the latter is
|
||||
user-revocable and was observed denied by default on Android 16,
|
||||
@ -9,9 +13,18 @@
|
||||
qualifying use, and it is granted at install with no runtime prompt. -->
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Coarse is enough for a 150 m geofence and is the cheaper grant to ask
|
||||
for; background access is what makes the gate work while the screen is
|
||||
off, and is requested separately at runtime. -->
|
||||
<!-- FINE is required, not a nicety. Coarse was assumed "enough for a 150 m
|
||||
geofence"; it is not. Android 12+ fuzzes ACCESS_COARSE_LOCATION to a
|
||||
~1-2 km grid that is re-randomised periodically, so a coarse fix taken
|
||||
at home routinely lands outside a 150 m fence, which reads as AWAY
|
||||
and restores every app the geofence exists to hide. Measured 2026-08-14:
|
||||
only COARSE was held, and the phone alternated between blocking nothing
|
||||
at home and blocking everything at the office.
|
||||
COARSE stays declared so a partial grant still yields a (fuzzed) fix
|
||||
rather than none at all. Both are self-granted by the device owner in
|
||||
DevicePolicyBridge.grantLocationPermissions(); background access is
|
||||
what makes the gate work while the screen is off. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<!-- Needed to enumerate installed packages for the allowlist sweep. -->
|
||||
@ -63,7 +76,13 @@
|
||||
<service
|
||||
android:name=".EnforcementService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
android:foregroundServiceType="specialUse|location">
|
||||
<!-- "location" as well as "specialUse": the pass actively requests
|
||||
a fix, and background location delivery is throttled to a few
|
||||
times an hour unless it happens under a location-typed
|
||||
foreground service. Without it the request usually times out,
|
||||
which fails closed and blocks everything. -->
|
||||
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Applies the device-owner focus policy at scheduled times" />
|
||||
@ -84,6 +103,14 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<!-- Installing over the app cancels its pending alarms, so
|
||||
without this an `adb install -r` silently stops
|
||||
enforcement until someone opens the app. Measured
|
||||
2026-08-14: an install at 13:31 dropped the 13:34 alarm,
|
||||
and the chain only resumed because the app was launched.
|
||||
A deploy during curfew would otherwise leave the curfew
|
||||
allowlist applied indefinitely. -->
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@ -25,7 +25,14 @@ class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_BOOT_COMPLETED -> {
|
||||
// MY_PACKAGE_REPLACED for the same reason as the boot actions:
|
||||
// installing over the app cancels its pending alarms, so without
|
||||
// it the chain stays dead until someone opens the app. It arrives
|
||||
// only for this package and only after the install completes.
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
-> {
|
||||
val unlocked = context.getSystemService(UserManager::class.java)
|
||||
?.isUserUnlocked ?: false
|
||||
Log.i(
|
||||
|
||||
@ -65,6 +65,60 @@ class DevicePolicyBridge(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-grants the location permissions the geofence needs.
|
||||
*
|
||||
* A device owner can grant its own runtime permissions with no user
|
||||
* prompt, which matters here for two reasons. First, FINE is required:
|
||||
* COARSE is fuzzed to a ~1-2 km grid on Android 12+, against a 150 m
|
||||
* fence, so a coarse-only build misclassifies home as AWAY and restores
|
||||
* everything the geofence exists to hide. Second, background location was
|
||||
* previously granted by hand over adb, which is invisible state that a
|
||||
* reinstall silently loses -- and losing it fails closed, blocking
|
||||
* everything with no on-screen explanation.
|
||||
*
|
||||
* Re-asserted on every pass rather than once at provisioning, matching the
|
||||
* VPN and private-DNS pins: the protection then self-heals instead of
|
||||
* depending on anyone remembering.
|
||||
*
|
||||
* @return the permissions that are granted after this call.
|
||||
*/
|
||||
fun grantLocationPermissions(): Set<String> {
|
||||
if (!isDeviceOwner() && !isProfileOwner()) return emptySet()
|
||||
return LOCATION_PERMISSIONS.filterTo(mutableSetOf()) { permission ->
|
||||
runCatching {
|
||||
dpm.setPermissionGrantState(
|
||||
admin,
|
||||
context.packageName,
|
||||
permission,
|
||||
DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED,
|
||||
)
|
||||
// Read back rather than trusting the setter: setPermissionGrantState
|
||||
// returns false for a permission the platform refuses to
|
||||
// auto-grant, and ACCESS_BACKGROUND_LOCATION is exactly the
|
||||
// kind of permission that can be refused.
|
||||
dpm.getPermissionGrantState(admin, context.packageName, permission) ==
|
||||
DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
|
||||
}.getOrElse { error ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "could not grant $permission", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns location services on, so a fix is obtainable at all. */
|
||||
fun enableLocation(): Boolean {
|
||||
if (!isDeviceOwner()) return false
|
||||
return runCatching {
|
||||
dpm.setLocationEnabled(admin, true)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
// Not fatal: the pass still runs and fails closed without a fix.
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "could not enable location", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a package is currently hidden by this admin. */
|
||||
fun isApplicationHidden(packageName: String): Boolean {
|
||||
if (!isDeviceOwner() && !isProfileOwner()) return false
|
||||
@ -75,6 +129,32 @@ class DevicePolicyBridge(private val context: Context) {
|
||||
/** Whether the admin component is active (a weaker state than ownership). */
|
||||
fun isAdminActive(): Boolean = dpm.isAdminActive(admin)
|
||||
|
||||
/**
|
||||
* Whether any account exists, which decides if releasing is reversible.
|
||||
*
|
||||
* `dpm set-device-owner` refuses to run while the device has accounts, so
|
||||
* once one is added, releasing ownership can only be undone by a factory
|
||||
* reset. The release dialog says so, and it must say so truthfully rather
|
||||
* than assume: during provisioning the count is zero and releasing is
|
||||
* cheap, which is exactly when the escape hatch should be exercised.
|
||||
*/
|
||||
fun hasAccounts(): Boolean = runCatching {
|
||||
// Measured on device rather than assumed: with GET_ACCOUNTS undeclared
|
||||
// this returned 5 accounts while dumpsys reported 6, so the call works
|
||||
// without the runtime permission but does not necessarily see every
|
||||
// account. Only "any account at all" is needed here, so the visibility
|
||||
// gap does not matter -- and a permission prompt would be a heavy
|
||||
// price for one warning string.
|
||||
android.accounts.AccountManager.get(context)
|
||||
.getAccountsByType(null)
|
||||
.isNotEmpty()
|
||||
}.getOrElse { error ->
|
||||
// Fail toward the scarier message: claiming a release is cheap when it
|
||||
// is not is the more damaging of the two possible wrong answers.
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "could not read accounts", error)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks or unblocks uninstalling this app.
|
||||
*
|
||||
@ -257,6 +337,7 @@ class DevicePolicyBridge(private val context: Context) {
|
||||
"restrictionsApplied" to false,
|
||||
"uninstallBlocked" to isSelfUninstallBlocked(),
|
||||
"alwaysOnVpn" to (alwaysOnVpnPackage() ?: ""),
|
||||
"hasAccounts" to hasAccounts(),
|
||||
)
|
||||
|
||||
/**
|
||||
@ -316,4 +397,19 @@ class DevicePolicyBridge(private val context: Context) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Location permissions self-granted every pass.
|
||||
*
|
||||
* Ordered coarse-then-fine-then-background because that is the order
|
||||
* the platform expects them to be escalated in; granting background
|
||||
* without a foreground grant in place is refused.
|
||||
*/
|
||||
val LOCATION_PERMISSIONS = listOf(
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,26 @@ import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Why a particular package is hidden. */
|
||||
enum class HideReason {
|
||||
/** Hidden in every state, exempt from the geofence. */
|
||||
ALWAYS_BLOCKED,
|
||||
|
||||
/** Not on the day allowlist. */
|
||||
NOT_IN_ALLOWLIST,
|
||||
|
||||
/** On the day allowlist but not the shorter curfew one. */
|
||||
NOT_IN_NIGHT_ALLOWLIST,
|
||||
}
|
||||
|
||||
/** The fence geometry behind one decision. */
|
||||
data class Geofence(
|
||||
/** Metres from home, or null when no coordinates are configured. */
|
||||
val metres: Double?,
|
||||
val threshold: Double,
|
||||
val inside: Boolean,
|
||||
)
|
||||
|
||||
/** Why enforcement is currently on or off. */
|
||||
enum class EnforcementReason {
|
||||
AWAY,
|
||||
@ -20,21 +40,71 @@ data class EnforcementInputs(
|
||||
val minutesSinceMidnight: Int,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
/**
|
||||
* Whether the previous pass placed the phone inside the fence.
|
||||
*
|
||||
* Widens the threshold by the hysteresis margin, so a phone parked on the
|
||||
* boundary does not flap between states.
|
||||
*/
|
||||
val currentlyEnforcing: Boolean = false,
|
||||
val workoutActive: Boolean = false,
|
||||
/**
|
||||
* The fix these coordinates came from, carried for the log only.
|
||||
*
|
||||
* Never consulted by [EnforcementDecision.evaluate]: staleness is applied
|
||||
* by the caller, which withholds the coordinates outright. Keeping the
|
||||
* decision a function of latitude/longitude alone is what lets it stay
|
||||
* testable without a location provider.
|
||||
*/
|
||||
val fix: LocationFix? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The outcome of evaluating the policy against current conditions.
|
||||
*
|
||||
* A pure function of its inputs, so it can be tested without a device, a clock
|
||||
* or a location provider. Mirrors `lib/enforcement.dart`; `PolicyParityTest`
|
||||
* pins the two against shared fixtures.
|
||||
* or a location provider.
|
||||
*
|
||||
* This is the ONLY implementation that enforces anything. `lib/enforcement.dart`
|
||||
* used to be described here as a mirror pinned by a `PolicyParityTest`; no such
|
||||
* test ever existed, and the two had silently drifted -- the Dart copy has no
|
||||
* always-blocked concept at all, so it would report YouTube as visible while it
|
||||
* is in fact hidden. The UI therefore renders the record this class produces
|
||||
* rather than re-deciding anything, which removes the drift instead of policing
|
||||
* it.
|
||||
*/
|
||||
data class EnforcementDecision(
|
||||
val reason: EnforcementReason,
|
||||
val packagesToHide: Set<String>,
|
||||
val packagesToShow: Set<String>,
|
||||
/**
|
||||
* Metres from home, or null when there was no fix or no coordinates.
|
||||
*
|
||||
* Reported so the phone can explain itself. Coordinates are deliberately
|
||||
* NOT carried: a distance is enough to debug the geofence, and logcat is
|
||||
* readable by anyone with adb.
|
||||
*/
|
||||
val distanceM: Double? = null,
|
||||
/** The value [distanceM] was compared against, including hysteresis. */
|
||||
val thresholdM: Double? = null,
|
||||
/**
|
||||
* Whether the fence returned a verdict, or null when it could not.
|
||||
*
|
||||
* Null is not the same as false: it means the question was unanswerable,
|
||||
* so the caller leaves the previous verdict standing instead of recording
|
||||
* "outside" and losing hysteresis at the worst moment.
|
||||
*/
|
||||
val insideFence: Boolean? = null,
|
||||
/** Why each hidden package is hidden, for the UI and the log. */
|
||||
val hideReasons: Map<String, HideReason> = emptyMap(),
|
||||
/**
|
||||
* Whether the clock is inside the curfew window.
|
||||
*
|
||||
* Carried separately from [reason] because the two disagree: at 23:30 away
|
||||
* from home the reason is AWAY, and inferring curfew from the enum would
|
||||
* report "not in curfew" in exactly the branches where it is least obvious.
|
||||
*/
|
||||
val curfewActive: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Whether this pass leaves anything hidden.
|
||||
@ -67,7 +137,15 @@ data class EnforcementDecision(
|
||||
val lat = inputs.latitude
|
||||
val lon = inputs.longitude
|
||||
val hasFix = lat != null && lon != null
|
||||
val atHome = hasFix && isInside(policy, lat, lon, inputs.currentlyEnforcing)
|
||||
val fence = if (hasFix) {
|
||||
geofence(policy, lat, lon, inputs.currentlyEnforcing)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val atHome = fence?.inside ?: false
|
||||
// Computed before the AWAY return so every branch can report it.
|
||||
// The window is a property of the clock, not of where the phone is.
|
||||
val duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight)
|
||||
|
||||
// Exempt from every branch below, including AWAY and WORKOUT.
|
||||
// Restricted to packages actually present, so the sets stay a
|
||||
@ -82,10 +160,14 @@ data class EnforcementDecision(
|
||||
reason = EnforcementReason.AWAY,
|
||||
packagesToHide = alwaysBlocked,
|
||||
packagesToShow = inputs.installedPackages - alwaysBlocked,
|
||||
distanceM = fence?.metres,
|
||||
thresholdM = fence?.threshold,
|
||||
insideFence = fence?.inside,
|
||||
hideReasons = alwaysBlocked.associateWith { HideReason.ALWAYS_BLOCKED },
|
||||
curfewActive = duringCurfew,
|
||||
)
|
||||
}
|
||||
|
||||
val duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight)
|
||||
val reason = when {
|
||||
!hasFix -> EnforcementReason.LOCATION_UNKNOWN
|
||||
duringCurfew -> EnforcementReason.CURFEW
|
||||
@ -94,13 +176,20 @@ data class EnforcementDecision(
|
||||
|
||||
val hide = mutableSetOf<String>()
|
||||
val show = mutableSetOf<String>()
|
||||
val why = mutableMapOf<String, HideReason>()
|
||||
for (pkg in inputs.installedPackages) {
|
||||
if (pkg in alwaysBlocked) {
|
||||
hide.add(pkg)
|
||||
why[pkg] = HideReason.ALWAYS_BLOCKED
|
||||
} else if (policy.isAllowed(pkg, duringCurfew)) {
|
||||
show.add(pkg)
|
||||
} else {
|
||||
hide.add(pkg)
|
||||
why[pkg] = if (duringCurfew) {
|
||||
HideReason.NOT_IN_NIGHT_ALLOWLIST
|
||||
} else {
|
||||
HideReason.NOT_IN_ALLOWLIST
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,30 +198,63 @@ data class EnforcementDecision(
|
||||
// A workout exemption must not become a way to reach the
|
||||
// always-blocked set, or "go for a run" is the off switch.
|
||||
if (pkg in alwaysBlocked) continue
|
||||
if (hide.remove(pkg)) show.add(pkg)
|
||||
if (hide.remove(pkg)) {
|
||||
show.add(pkg)
|
||||
why.remove(pkg)
|
||||
}
|
||||
return EnforcementDecision(EnforcementReason.WORKOUT, hide, show)
|
||||
}
|
||||
return EnforcementDecision(reason, hide, show)
|
||||
return EnforcementDecision(
|
||||
reason = EnforcementReason.WORKOUT,
|
||||
packagesToHide = hide,
|
||||
packagesToShow = show,
|
||||
distanceM = fence?.metres,
|
||||
thresholdM = fence?.threshold,
|
||||
insideFence = fence?.inside,
|
||||
hideReasons = why,
|
||||
curfewActive = duringCurfew,
|
||||
)
|
||||
}
|
||||
return EnforcementDecision(
|
||||
reason = reason,
|
||||
packagesToHide = hide,
|
||||
packagesToShow = show,
|
||||
distanceM = fence?.metres,
|
||||
thresholdM = fence?.threshold,
|
||||
insideFence = fence?.inside,
|
||||
hideReasons = why,
|
||||
curfewActive = duringCurfew,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isInside(
|
||||
/**
|
||||
* Evaluates the fence, returning the geometry as well as the verdict.
|
||||
*
|
||||
* The distance and the threshold are returned rather than discarded so
|
||||
* the app can say *why* it decided what it decided. Without them the
|
||||
* three states are indistinguishable from outside, which is what made
|
||||
* the original misbehaviour impossible to diagnose from the phone.
|
||||
*/
|
||||
private fun geofence(
|
||||
policy: FocusPolicy,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
currentlyEnforcing: Boolean,
|
||||
): Boolean {
|
||||
): Geofence {
|
||||
val home = policy.home
|
||||
val threshold = home.radiusM + if (currentlyEnforcing) home.hysteresisM else 0.0
|
||||
// No coordinates configured: refuse to claim the user is away.
|
||||
if (!home.hasCoordinates) return true
|
||||
// Reported as inside with an unknown distance, so the UI can say
|
||||
// "no home set" rather than presenting a fabricated 0 m.
|
||||
if (!home.hasCoordinates) {
|
||||
return Geofence(metres = null, threshold = threshold, inside = true)
|
||||
}
|
||||
val metres = haversineMetres(
|
||||
home.latitude!!,
|
||||
home.longitude!!,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
val threshold = home.radiusM + if (currentlyEnforcing) home.hysteresisM else 0.0
|
||||
return metres <= threshold
|
||||
return Geofence(metres, threshold, inside = metres <= threshold)
|
||||
}
|
||||
|
||||
private fun haversineMetres(
|
||||
|
||||
@ -0,0 +1,223 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Append-only, size-capped record of enforcement passes.
|
||||
*
|
||||
* Read back through the platform channel rather than adb, because neither adb
|
||||
* route works on this build: `run-as` returns "package not debuggable" on the
|
||||
* release build device owner requires (see [EnforcementRunner.setHomeToCurrentLocation]),
|
||||
* and logcat rotates -- measured 2026-08-14, `adb logcat -d -s FocusOwner:V`
|
||||
* came back empty while `dumpsys alarm` showed 82 alarms had fired. An in-app
|
||||
* reader is the only durable option, so this file plus a UI is the deliverable.
|
||||
*
|
||||
* Never records coordinates. Distance in metres, fix age, accuracy and
|
||||
* provider carry everything needed to debug the geofence without writing down
|
||||
* where the user lives.
|
||||
*/
|
||||
class EnforcementLog(private val directory: File) {
|
||||
|
||||
constructor(context: Context) : this(context.filesDir)
|
||||
|
||||
private val file: File get() = File(directory, FILE_NAME)
|
||||
|
||||
/**
|
||||
* Appends one record.
|
||||
*
|
||||
* Every failure is swallowed. This is instrumentation: a logging problem
|
||||
* must never be able to fail an enforcement pass, because the pass running
|
||||
* is the app's actual promise.
|
||||
*/
|
||||
fun append(record: JSONObject) {
|
||||
runCatching {
|
||||
if (!directory.exists()) directory.mkdirs()
|
||||
file.appendText(record.toString() + "\n")
|
||||
rotateIfNeeded()
|
||||
}.onFailure {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "could not append enforcement record", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent records, newest first.
|
||||
*
|
||||
* A corrupt line is skipped rather than failing the read: a half-written
|
||||
* record from a process killed mid-append must not cost the whole history,
|
||||
* which is exactly when the history is most wanted.
|
||||
*/
|
||||
fun readRecent(limit: Int = DEFAULT_LIMIT): List<String> = runCatching {
|
||||
if (!file.exists()) return emptyList()
|
||||
file.readLines()
|
||||
.asReversed()
|
||||
.asSequence()
|
||||
.filter { it.isNotBlank() }
|
||||
.filter { line -> runCatching { JSONObject(line) }.isSuccess }
|
||||
.take(limit)
|
||||
.toList()
|
||||
}.getOrElse {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "could not read enforcement log", it)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims the file once it grows past [MAX_BYTES].
|
||||
*
|
||||
* Rotates by whole lines rather than truncating bytes, so the file can
|
||||
* never be left starting mid-record, which would make the oldest surviving
|
||||
* entry unparsable.
|
||||
*/
|
||||
private fun rotateIfNeeded() {
|
||||
if (file.length() <= MAX_BYTES) return
|
||||
val kept = file.readLines().filter { it.isNotBlank() }.takeLast(KEEP_LINES)
|
||||
file.writeText(kept.joinToString("\n", postfix = "\n"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_NAME = "enforcement_log.jsonl"
|
||||
|
||||
/**
|
||||
* Byte size that triggers a trim.
|
||||
*
|
||||
* NOT the resulting file size, and not a ceiling. A trim drops the
|
||||
* file to [KEEP_LINES] whole records and appends resume until this is
|
||||
* crossed again, so the steady state is 400 records plus one trim
|
||||
* interval -- measured at 492 lines after 1200 appends. Measured
|
||||
* against this device (~32 sweepable packages): ~730 B/record at home
|
||||
* and ~1.7 KB during curfew when 17 apps are hidden, so the file sits
|
||||
* somewhere around 300-700 KB. Bounded, just not by this number.
|
||||
*/
|
||||
private const val MAX_BYTES = 256L * 1024L
|
||||
|
||||
/** The real bound: ~400 records is a little over four days at 15 min. */
|
||||
private const val KEEP_LINES = 400
|
||||
private const val DEFAULT_LIMIT = 200
|
||||
|
||||
/** Schema version of the records written by this build. */
|
||||
const val SCHEMA_VERSION = 1
|
||||
|
||||
/**
|
||||
* Renders one pass as a record.
|
||||
*
|
||||
* Deliberately a pure function of its arguments so it can be tested
|
||||
* without a device, and so it is obvious by inspection that no
|
||||
* coordinate reaches it: it takes a [LocationFix] but reads only the
|
||||
* age, accuracy, provider and outcome.
|
||||
*/
|
||||
fun record(
|
||||
timestampMs: Long,
|
||||
decision: EnforcementDecision,
|
||||
fix: LocationFix?,
|
||||
policy: FocusPolicy?,
|
||||
homeConfigured: Boolean,
|
||||
permissions: Set<String>,
|
||||
hidDelta: List<String>,
|
||||
restoredDelta: List<String>,
|
||||
failure: String? = null,
|
||||
): JSONObject = JSONObject().apply {
|
||||
put("v", SCHEMA_VERSION)
|
||||
put("ts", timestampMs)
|
||||
put("reason", decision.reason.name)
|
||||
put("distance_m", decision.distanceM ?: JSONObject.NULL)
|
||||
put("threshold_m", decision.thresholdM ?: JSONObject.NULL)
|
||||
put("inside_fence", decision.insideFence ?: JSONObject.NULL)
|
||||
put("home_configured", homeConfigured)
|
||||
put(
|
||||
"fix",
|
||||
JSONObject().apply {
|
||||
put("age_ms", fix?.takeIf { it.hasCoordinates }?.ageMs ?: JSONObject.NULL)
|
||||
put("provider", fix?.provider ?: JSONObject.NULL)
|
||||
put("accuracy_m", fix?.accuracyM ?: JSONObject.NULL)
|
||||
put("outcome", fix?.outcome?.name ?: JSONObject.NULL)
|
||||
},
|
||||
)
|
||||
put("permissions", JSONArray(permissions.sorted()))
|
||||
// From the clock, not inferred from the reason: at 23:30 away from
|
||||
// home the reason is AWAY while the curfew is genuinely in force.
|
||||
put("curfew_active", decision.curfewActive)
|
||||
put(
|
||||
"curfew_window",
|
||||
policy?.curfew?.let { "${hhmm(it.startMinutes)}-${hhmm(it.endMinutes)}" }
|
||||
?: JSONObject.NULL,
|
||||
)
|
||||
put(
|
||||
"counts",
|
||||
JSONObject().apply {
|
||||
put("to_hide", decision.packagesToHide.size)
|
||||
put("to_show", decision.packagesToShow.size)
|
||||
put("hid_delta", hidDelta.size)
|
||||
put("restored_delta", restoredDelta.size)
|
||||
},
|
||||
)
|
||||
put(
|
||||
"hidden",
|
||||
JSONArray().apply {
|
||||
decision.packagesToHide.sorted().forEach { pkg ->
|
||||
put(
|
||||
JSONObject()
|
||||
.put("pkg", pkg)
|
||||
.put(
|
||||
"why",
|
||||
decision.hideReasons[pkg]?.name ?: "UNKNOWN",
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
put("hid", JSONArray(hidDelta.sorted()))
|
||||
put("restored", JSONArray(restoredDelta.sorted()))
|
||||
put("failure", failure ?: JSONObject.NULL)
|
||||
}
|
||||
|
||||
/**
|
||||
* A record for a pass that produced no decision at all.
|
||||
*
|
||||
* Written from the early-return paths -- not provisioned, unreadable
|
||||
* policy -- because those are precisely the states behind "it just
|
||||
* stopped working and nothing says why", and without this they leave
|
||||
* no trace anywhere the phone can show.
|
||||
*/
|
||||
fun failureRecord(timestampMs: Long, failure: String): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("v", SCHEMA_VERSION)
|
||||
put("ts", timestampMs)
|
||||
put("reason", "NO_DECISION")
|
||||
put("distance_m", JSONObject.NULL)
|
||||
put("threshold_m", JSONObject.NULL)
|
||||
put("inside_fence", JSONObject.NULL)
|
||||
put("home_configured", JSONObject.NULL)
|
||||
put(
|
||||
"fix",
|
||||
JSONObject().apply {
|
||||
put("age_ms", JSONObject.NULL)
|
||||
put("provider", JSONObject.NULL)
|
||||
put("accuracy_m", JSONObject.NULL)
|
||||
put("outcome", JSONObject.NULL)
|
||||
},
|
||||
)
|
||||
put("permissions", JSONArray())
|
||||
put("curfew_active", JSONObject.NULL)
|
||||
put("curfew_window", JSONObject.NULL)
|
||||
put(
|
||||
"counts",
|
||||
JSONObject().apply {
|
||||
put("to_hide", 0)
|
||||
put("to_show", 0)
|
||||
put("hid_delta", 0)
|
||||
put("restored_delta", 0)
|
||||
},
|
||||
)
|
||||
put("hidden", JSONArray())
|
||||
put("hid", JSONArray())
|
||||
put("restored", JSONArray())
|
||||
put("failure", failure)
|
||||
}
|
||||
|
||||
private fun hhmm(minutes: Int): String =
|
||||
"%02d:%02d".format(minutes / 60, minutes % 60)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.kuhy.focus_owner
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
@ -19,6 +20,22 @@ import java.util.Calendar
|
||||
*/
|
||||
class EnforcementRunner(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Inputs from the current pass, carried from [decide] to [apply].
|
||||
*
|
||||
* The record needs both halves: the fix and whether home is configured are
|
||||
* only visible while deciding, the hide/show deltas only while applying.
|
||||
* This is why [EnforcementService] uses ONE runner for both calls -- a
|
||||
* second instance would log a pass with no fix metadata at all.
|
||||
*/
|
||||
private var pass: PassContext? = null
|
||||
|
||||
private data class PassContext(
|
||||
val fix: LocationFix,
|
||||
val homeConfigured: Boolean,
|
||||
val policy: FocusPolicy?,
|
||||
)
|
||||
|
||||
/** Builds the decision, or null when the policy cannot be read. */
|
||||
fun decide(): EnforcementDecision? {
|
||||
val policy = try {
|
||||
@ -31,7 +48,13 @@ class EnforcementRunner(private val context: Context) {
|
||||
}
|
||||
|
||||
val home = readHomeLocation()
|
||||
val fix = if (home == null) null else lastKnownLocation()
|
||||
// Acquired even when no home is configured, so the log can tell
|
||||
// "never provisioned" apart from "could not get a fix" -- previously
|
||||
// both produced an identical LOCATION_UNKNOWN with nothing to
|
||||
// distinguish them.
|
||||
val fix = acquireLocation()
|
||||
val fresh = fix.isFresh(FRESH_WINDOW_MS)
|
||||
pass = PassContext(fix = fix, homeConfigured = home != null, policy = policy)
|
||||
val calendar = Calendar.getInstance()
|
||||
val minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE)
|
||||
|
||||
@ -51,10 +74,21 @@ class EnforcementRunner(private val context: Context) {
|
||||
EnforcementInputs(
|
||||
installedPackages = sweepablePackages(effective),
|
||||
minutesSinceMidnight = minutes,
|
||||
latitude = fix?.first,
|
||||
longitude = fix?.second,
|
||||
currentlyEnforcing = wasEnforcing(),
|
||||
// A stale fix is withheld entirely rather than passed with its
|
||||
// age attached: it must not be able to classify the phone as
|
||||
// AWAY. Fail-closed here is only safe because acquireLocation
|
||||
// now actively requests a fix, so "stale" is the exception
|
||||
// rather than the steady state.
|
||||
latitude = if (fresh) fix.latitude else null,
|
||||
longitude = if (fresh) fix.longitude else null,
|
||||
// Keyed off whether the last pass placed the phone inside the
|
||||
// fence, NOT off whether it hid anything. AWAY still hides the
|
||||
// always-blocked set, so the old `wasEnforcing()` was true in
|
||||
// every state and the threshold was permanently radius +
|
||||
// hysteresis -- biasing every borderline call toward AT_HOME.
|
||||
currentlyEnforcing = wasInsideFence(),
|
||||
workoutActive = false,
|
||||
fix = fix,
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -72,6 +106,15 @@ class EnforcementRunner(private val context: Context) {
|
||||
// the VPN pin is the one step that should survive a policy problem.
|
||||
val policy = runCatching { FocusPolicy.load(context) }.getOrNull()
|
||||
|
||||
// Re-asserted every pass for the same self-healing reason as the pins
|
||||
// below. The geofence is only as good as its input, and a lost FINE
|
||||
// grant degrades it silently: coarse location is fuzzed to a ~1-2 km
|
||||
// grid, so the phone starts reading home as AWAY with nothing on
|
||||
// screen to say why. Background location was previously granted by
|
||||
// hand over adb, which a reinstall would have quietly dropped.
|
||||
val granted = bridge.grantLocationPermissions()
|
||||
bridge.enableLocation()
|
||||
|
||||
// Re-pinned every pass, unconditionally rather than tracking the
|
||||
// decision: the network filter is the layer that reaches youtube.com
|
||||
// in Firefox, in a webview, and in clients nobody has enumerated, so
|
||||
@ -106,11 +149,13 @@ class EnforcementRunner(private val context: Context) {
|
||||
// state where enforcement is already off.
|
||||
bridge.setSelfUninstallBlocked(decision.isEnforcing)
|
||||
|
||||
var shown = 0
|
||||
var hidden = 0
|
||||
// Named rather than counted, so the log can answer "what changed on
|
||||
// the pass that broke it" instead of only "how many".
|
||||
val restoredNow = mutableListOf<String>()
|
||||
val hiddenNow = mutableListOf<String>()
|
||||
for (pkg in decision.packagesToShow) {
|
||||
if (bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, false)) shown++
|
||||
if (bridge.setApplicationHidden(pkg, false)) restoredNow.add(pkg)
|
||||
}
|
||||
}
|
||||
for (pkg in decision.packagesToHide) {
|
||||
@ -122,13 +167,34 @@ class EnforcementRunner(private val context: Context) {
|
||||
// otherwise hide the app on the first enforcing pass.
|
||||
if (pkg == context.packageName) continue
|
||||
if (!bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, true)) hidden++
|
||||
if (bridge.setApplicationHidden(pkg, true)) hiddenNow.add(pkg)
|
||||
}
|
||||
}
|
||||
rememberEnforcing(decision.isEnforcing)
|
||||
// Only recorded when the fence actually returned an answer. A pass
|
||||
// with no usable fix must leave the previous verdict standing rather
|
||||
// than resetting it to "outside", which would drop hysteresis for the
|
||||
// next pass at exactly the moment the input is least reliable.
|
||||
decision.insideFence?.let { rememberInsideFence(it) }
|
||||
// Durable, in-app-readable record. Logcat is kept for live tailing but
|
||||
// cannot be relied on: it rotates, and `run-as` is refused on this
|
||||
// build, so logcat alone left the phone unable to explain itself.
|
||||
EnforcementLog(context).append(
|
||||
EnforcementLog.record(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
decision = decision,
|
||||
fix = pass?.fix,
|
||||
policy = pass?.policy ?: policy,
|
||||
homeConfigured = pass?.homeConfigured ?: hasHomeLocation(),
|
||||
permissions = granted,
|
||||
hidDelta = hiddenNow,
|
||||
restoredDelta = restoredNow,
|
||||
),
|
||||
)
|
||||
Log.i(
|
||||
FocusDeviceAdminReceiver.TAG,
|
||||
"applied ${decision.reason}: hid $hidden, restored $shown",
|
||||
"applied ${decision.reason}: hid ${hiddenNow.size}, " +
|
||||
"restored ${restoredNow.size}",
|
||||
)
|
||||
}
|
||||
|
||||
@ -183,12 +249,42 @@ class EnforcementRunner(private val context: Context) {
|
||||
* @return null on success, or a message describing why it failed.
|
||||
*/
|
||||
fun setHomeToCurrentLocation(): String? {
|
||||
val fix = lastKnownLocation()
|
||||
?: return "no location fix available - open a maps app, then retry"
|
||||
// Its own thresholds, so a retry actually requests a new fix rather
|
||||
// than being handed the same cached one it just rejected.
|
||||
val fix = acquireLocation(
|
||||
freshWindowMs = SET_HOME_MAX_AGE_MS,
|
||||
maxAccuracyM = SET_HOME_MAX_ACCURACY_M,
|
||||
)
|
||||
if (!fix.hasCoordinates) {
|
||||
return when (fix.outcome) {
|
||||
FixOutcome.NO_PERMISSION -> "location permission is not granted"
|
||||
FixOutcome.NO_PROVIDER -> "location services are off - turn them on"
|
||||
else -> "no location fix available - go outside or open a maps app, then retry"
|
||||
}
|
||||
}
|
||||
// A wrong home is worse than no home: it silently inverts the geofence
|
||||
// everywhere, forever, and nothing on screen would say so. So this
|
||||
// refuses anything but a fix taken now -- unlike an enforcement pass,
|
||||
// there is a user standing here who can simply retry.
|
||||
if (!fix.isFresh(SET_HOME_MAX_AGE_MS)) {
|
||||
return "only a stale fix (${fix.ageMs / 1000}s old) - wait a moment and retry"
|
||||
}
|
||||
// Unknown accuracy reads as unacceptable, matching `best` and the
|
||||
// cached-fix gate. The opposite reading would let a fix of unknown
|
||||
// precision anchor home, which silently inverts the geofence
|
||||
// everywhere and forever -- and unlike an enforcement pass, there is a
|
||||
// user present who can simply step outside and retry.
|
||||
val accuracy = fix.accuracyM ?: Double.MAX_VALUE
|
||||
if (accuracy > SET_HOME_MAX_ACCURACY_M) {
|
||||
val reported = fix.accuracyM?.toInt()?.let { "$it m" } ?: "unknown"
|
||||
return "fix accuracy is $reported (need under " +
|
||||
"${SET_HOME_MAX_ACCURACY_M.toInt()} m) - " +
|
||||
"go outside for a better signal, then retry"
|
||||
}
|
||||
return try {
|
||||
val json = JSONObject()
|
||||
.put("latitude", fix.first)
|
||||
.put("longitude", fix.second)
|
||||
.put("latitude", fix.latitude)
|
||||
.put("longitude", fix.longitude)
|
||||
File(context.filesDir, "home_location.json").writeText(json.toString())
|
||||
// Never log the coordinates: this is the user's home address and
|
||||
// logcat is world-readable to anyone with adb.
|
||||
@ -220,36 +316,249 @@ class EnforcementRunner(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The most recent fix, or null when unavailable or not permitted. */
|
||||
private fun lastKnownLocation(): Pair<Double, Double>? {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
/**
|
||||
* Acquires a fix, preferring a fresh one over whatever happens to be cached.
|
||||
*
|
||||
* The old implementation only read [LocationManager.getLastKnownLocation]
|
||||
* and took the newest across providers, with no age check and no request
|
||||
* of its own. That produced both reported failures: at the office with no
|
||||
* navigation app running the cache was empty, so the pass fell through to
|
||||
* LOCATION_UNKNOWN and blocked everything; and a cached fix from hours ago
|
||||
* would have classified the office as home just as readily.
|
||||
*
|
||||
* Bounded by [ACQUIRE_TIMEOUT_MS] so a pass can never hang waiting for a
|
||||
* fix that is not coming. On expiry it falls back to the cache, tagged so
|
||||
* the decision layer and the log can both say the acquisition timed out
|
||||
* rather than silently presenting a stale fix as current.
|
||||
*/
|
||||
fun acquireLocation(
|
||||
timeoutMs: Long = ACQUIRE_TIMEOUT_MS,
|
||||
freshWindowMs: Long = FRESH_WINDOW_MS,
|
||||
maxAccuracyM: Double? = null,
|
||||
): LocationFix {
|
||||
val fine = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
return try {
|
||||
if (!fine && !coarse) return LocationFix.unavailable(FixOutcome.NO_PERMISSION)
|
||||
val manager = context.getSystemService(LocationManager::class.java)
|
||||
?: return LocationFix.unavailable(FixOutcome.NO_PROVIDER)
|
||||
|
||||
// A recent cached fix is both cheaper and no less accurate than one we
|
||||
// would request right now, so it short-circuits acquisition entirely.
|
||||
// This is the common case whenever any app has used location lately.
|
||||
//
|
||||
// The caller's own thresholds are applied HERE, not only after the
|
||||
// fact: "Set home" demands a fix under 2 minutes old and accurate to
|
||||
// 100 m, and if this returned a 5-minute-old cache regardless, every
|
||||
// retry would hand back the identical rejected fix and the button
|
||||
// could never succeed until the cache aged out on its own.
|
||||
cachedFix(manager, freshWindowMs)
|
||||
?.takeIf { it.ageMs <= freshWindowMs }
|
||||
// Unknown accuracy is treated as unacceptable, matching `best`.
|
||||
// The alternative reading (0.0, i.e. perfect) would let a fix of
|
||||
// unknown precision anchor home, which silently inverts the
|
||||
// geofence everywhere and forever.
|
||||
?.takeIf {
|
||||
maxAccuracyM == null ||
|
||||
(it.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM
|
||||
}
|
||||
?.let { return it.copy(outcome = FixOutcome.CACHED_FRESH) }
|
||||
|
||||
val active = runCatching { requestCurrentFix(manager, timeoutMs) }
|
||||
.getOrElse { error ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location request failed", error)
|
||||
null
|
||||
}
|
||||
// The caller's accuracy bound applies to a freshly requested fix too,
|
||||
// not just the cache: a brand-new fix that cannot say where it is to
|
||||
// within maxAccuracyM answers the question no better than an old one.
|
||||
if (active != null &&
|
||||
(maxAccuracyM == null || (active.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM)
|
||||
) {
|
||||
return active.copy(outcome = FixOutcome.ACTIVE_OK)
|
||||
}
|
||||
|
||||
// Nothing fresh. Return the stale cache anyway, labelled: the decision
|
||||
// layer decides what to do with it, and the log needs to say what was
|
||||
// actually available rather than reporting a bare "no fix".
|
||||
// Window passed through so `best` takes its no-fresh-candidates branch
|
||||
// and reports the NEWEST stale fix. Without it every candidate counts
|
||||
// as fresh and the most accurate one wins, which does not change the
|
||||
// decision (the caller rejects anything stale either way) but makes
|
||||
// the log and the set-home error quote a six-hour-old fix when a
|
||||
// forty-minute-old one was available.
|
||||
val stale = cachedFix(manager, freshWindowMs)
|
||||
return stale?.copy(outcome = FixOutcome.TIMEOUT)
|
||||
?: LocationFix.unavailable(FixOutcome.TIMEOUT)
|
||||
}
|
||||
|
||||
/** The newest cached fix across enabled providers, or null. */
|
||||
private fun cachedFix(
|
||||
manager: LocationManager,
|
||||
freshWindowMs: Long = Long.MAX_VALUE,
|
||||
): LocationFix? = try {
|
||||
manager.getProviders(true)
|
||||
.asSequence()
|
||||
.mapNotNull { manager.getLastKnownLocation(it) }
|
||||
.maxByOrNull { it.time }
|
||||
?.let { it.latitude to it.longitude }
|
||||
.mapNotNull { provider ->
|
||||
manager.getLastKnownLocation(provider)?.let { provider to it }
|
||||
}
|
||||
.map { (provider, location) -> location.toFix(provider) }
|
||||
.toList()
|
||||
.let { best(it, freshWindowMs) }
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location denied", e)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the platform for a fix now, blocking up to [timeoutMs].
|
||||
*
|
||||
* Uses [LocationManager] rather than the fused Play Services client on
|
||||
* purpose: a device owner should not depend on Play Services, which is
|
||||
* itself inside this app's blast radius.
|
||||
*
|
||||
* The caller must not be on the main thread -- [EnforcementService] runs
|
||||
* the pass on a background executor for exactly this reason.
|
||||
*/
|
||||
private fun requestCurrentFix(manager: LocationManager, timeoutMs: Long): LocationFix? {
|
||||
val provider = PROVIDER_PREFERENCE.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: return null
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
val holder = java.util.concurrent.atomic.AtomicReference<Location?>()
|
||||
val signal = android.os.CancellationSignal()
|
||||
// The main executor rather than a fresh one per call: the consumer
|
||||
// only stores a reference and counts down, so it does no work worth a
|
||||
// thread, and creating one per pass leaked ~96 threads a day.
|
||||
manager.getCurrentLocation(
|
||||
provider,
|
||||
signal,
|
||||
androidx.core.content.ContextCompat.getMainExecutor(context),
|
||||
) { location ->
|
||||
holder.set(location)
|
||||
latch.countDown()
|
||||
}
|
||||
val answered = latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
if (!answered) {
|
||||
// Stop the provider working on a request nobody is waiting for.
|
||||
runCatching { signal.cancel() }
|
||||
return null
|
||||
}
|
||||
return holder.get()?.toFix(provider)
|
||||
}
|
||||
|
||||
/** Converts a platform [Location] into the fix the decision layer sees. */
|
||||
private fun Location.toFix(provider: String): LocationFix = LocationFix(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
// elapsedRealtimeNanos is monotonic, so a clock change cannot make a
|
||||
// fix look fresher than it is.
|
||||
ageMs = (android.os.SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos) /
|
||||
1_000_000L,
|
||||
accuracyM = if (hasAccuracy()) accuracy.toDouble() else null,
|
||||
provider = provider,
|
||||
outcome = FixOutcome.CACHED_FRESH,
|
||||
)
|
||||
|
||||
private fun prefs() =
|
||||
context.getSharedPreferences("enforcement", Context.MODE_PRIVATE)
|
||||
|
||||
/** Whether the previous pass was enforcing, for geofence hysteresis. */
|
||||
/** Whether the previous pass was enforcing. */
|
||||
private fun wasEnforcing(): Boolean = prefs().getBoolean(KEY_ENFORCING, false)
|
||||
|
||||
private fun rememberEnforcing(value: Boolean) =
|
||||
prefs().edit().putBoolean(KEY_ENFORCING, value).apply()
|
||||
|
||||
private companion object {
|
||||
const val KEY_ENFORCING = "was_enforcing"
|
||||
/**
|
||||
* Whether the previous pass placed the phone inside the fence.
|
||||
*
|
||||
* This, not [wasEnforcing], is what hysteresis must key off. Hysteresis
|
||||
* exists to stop a phone sitting on the boundary from flapping, so it has
|
||||
* to widen the fence only for someone already judged to be inside it.
|
||||
*/
|
||||
private fun wasInsideFence(): Boolean = prefs().getBoolean(KEY_WAS_INSIDE, false)
|
||||
|
||||
private fun rememberInsideFence(value: Boolean) =
|
||||
prefs().edit().putBoolean(KEY_WAS_INSIDE, value).apply()
|
||||
|
||||
companion object {
|
||||
private const val KEY_ENFORCING = "was_enforcing"
|
||||
|
||||
/** Whether the previous pass placed the phone inside the fence. */
|
||||
private const val KEY_WAS_INSIDE = "was_inside_fence"
|
||||
|
||||
/** Longest a pass will wait for a fresh fix before falling back. */
|
||||
const val ACQUIRE_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* A fix at most this old is trusted as describing "now".
|
||||
*
|
||||
* Deliberately longer than the 15-minute enforcement cadence. A window
|
||||
* below the cadence would mark the newest available cache stale on
|
||||
* every pass of a phone sitting still, sending every pass to
|
||||
* LOCATION_UNKNOWN and blocking everything -- turning the fail-closed
|
||||
* default from a safety net into the normal case.
|
||||
*/
|
||||
const val FRESH_WINDOW_MS = 30L * 60L * 1000L
|
||||
|
||||
/**
|
||||
* Much tighter than [FRESH_WINDOW_MS], for anchoring home.
|
||||
*
|
||||
* An enforcement pass tolerates an older fix because it re-runs every
|
||||
* 15 minutes and a wrong call self-corrects. Home is written once and
|
||||
* silently inverts the geofence everywhere if it is wrong, and there
|
||||
* is a user present who can just retry -- so it demands a fresh fix.
|
||||
*/
|
||||
const val SET_HOME_MAX_AGE_MS = 2L * 60L * 1000L
|
||||
|
||||
/** Refuse to anchor home to a fix vaguer than the fence is wide. */
|
||||
const val SET_HOME_MAX_ACCURACY_M = 100.0
|
||||
|
||||
/**
|
||||
* Picks the fix best able to answer "is the phone inside the fence".
|
||||
*
|
||||
* Freshness is a HARD GATE and accuracy is a preference *within* it,
|
||||
* in that order. Getting this backwards is not a style choice: sorting
|
||||
* by accuracy first can surface a very precise fix from this morning,
|
||||
* which the age check then rejects, sending the pass to
|
||||
* LOCATION_UNKNOWN and blocking everything -- the exact office
|
||||
* symptom. A recent coarse fix beats an old precise one because the
|
||||
* old one is describing somewhere the phone no longer is.
|
||||
*
|
||||
* Within the window, accuracy decides: a ±2 km network fix cannot
|
||||
* answer a 150 m question, so it loses to a ±10 m GPS fix even if it
|
||||
* is a minute newer. Unknown accuracy sorts last rather than first --
|
||||
* it might be anything, and this comparison exists to avoid guessing.
|
||||
*
|
||||
* When nothing is fresh, returns the newest stale fix so the log can
|
||||
* report what was actually available; the caller classifies it
|
||||
* unusable either way.
|
||||
*/
|
||||
fun best(candidates: List<LocationFix>, freshWindowMs: Long): LocationFix? {
|
||||
if (candidates.isEmpty()) return null
|
||||
val fresh = candidates.filter { it.ageMs <= freshWindowMs }
|
||||
if (fresh.isEmpty()) return candidates.minByOrNull { it.ageMs }
|
||||
return fresh.sortedWith(
|
||||
compareBy<LocationFix> { it.accuracyM ?: Double.MAX_VALUE }
|
||||
.thenBy { it.ageMs },
|
||||
).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers tried in order when requesting a fix.
|
||||
*
|
||||
* GPS first because it is the only one accurate enough for a 150 m
|
||||
* fence; fused and network are fallbacks that at least distinguish
|
||||
* "same city" from "10 km away".
|
||||
*/
|
||||
private val PROVIDER_PREFERENCE = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,8 +61,47 @@ class EnforcementScheduler(private val context: Context) {
|
||||
)?.let(alarms::cancel)
|
||||
}
|
||||
|
||||
private fun defaultNextRun(): Long =
|
||||
System.currentTimeMillis() + FALLBACK_INTERVAL_MS
|
||||
/**
|
||||
* The sooner of the periodic fallback and the next curfew boundary.
|
||||
*
|
||||
* The class comment has always described exact alarms at the boundaries,
|
||||
* but nothing ever computed one: both callers invoked `scheduleNext()`
|
||||
* bare, so the default was only ever `now + 15 min` and 05:00 could be
|
||||
* served up to fifteen minutes late -- exactly the "banking app still
|
||||
* missing in the morning" failure the comment warns about.
|
||||
*/
|
||||
private fun defaultNextRun(): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val fallback = now + FALLBACK_INTERVAL_MS
|
||||
val boundary = nextCurfewBoundary(now) ?: return fallback
|
||||
return minOf(fallback, boundary)
|
||||
}
|
||||
|
||||
/** When the curfew next opens or closes, or null when it is disabled. */
|
||||
private fun nextCurfewBoundary(now: Long): Long? {
|
||||
val curfew = runCatching { FocusPolicy.load(context) }.getOrNull()?.curfew
|
||||
?: return null
|
||||
return listOf(curfew.startMinutes, curfew.endMinutes)
|
||||
.map { nextOccurrenceOf(it, now) }
|
||||
.min()
|
||||
}
|
||||
|
||||
/** The next wall-clock instant at [minutesSinceMidnight], strictly after [now]. */
|
||||
private fun nextOccurrenceOf(minutesSinceMidnight: Int, now: Long): Long {
|
||||
val calendar = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = now
|
||||
set(java.util.Calendar.HOUR_OF_DAY, minutesSinceMidnight / 60)
|
||||
set(java.util.Calendar.MINUTE, minutesSinceMidnight % 60)
|
||||
set(java.util.Calendar.SECOND, 0)
|
||||
set(java.util.Calendar.MILLISECOND, 0)
|
||||
}
|
||||
// Rolled forward rather than clamped, so a boundary already passed
|
||||
// today schedules for tomorrow instead of firing immediately.
|
||||
if (calendar.timeInMillis <= now) {
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
return calendar.timeInMillis
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 100
|
||||
|
||||
@ -29,41 +29,125 @@ import android.util.Log
|
||||
*/
|
||||
class EnforcementService : Service() {
|
||||
|
||||
/** Single-threaded so overlapping starts queue rather than race. */
|
||||
private val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
runCatching { applyAndReschedule() }
|
||||
.onFailure { Log.e(FocusDeviceAdminReceiver.TAG, "enforcement run failed", it) }
|
||||
// The work is done; the alarm brings us back. START_NOT_STICKY because
|
||||
// a restart by the system would be a duplicate of the next alarm.
|
||||
// The LOCATION type is what lifts background location throttling for
|
||||
// the fix this pass is about to request. It is requested rather than
|
||||
// assumed: the platform refuses that type unless a location permission
|
||||
// is already held, and this app grants its own permissions *during* a
|
||||
// pass -- so the very first pass after a fresh install has none yet.
|
||||
// Measured 2026-08-14: passing it unconditionally threw
|
||||
// SecurityException and crashed the service on every alarm, which is
|
||||
// strictly worse than a throttled fix. Fall back to SPECIAL_USE alone.
|
||||
val foreground = startForegroundTyped(FGS_TYPE) ||
|
||||
startForegroundTyped(ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
if (!foreground) {
|
||||
// Neither type was accepted. Continuing would leave a started
|
||||
// service that never called startForeground, which the platform
|
||||
// kills with ForegroundServiceDidNotStartInTimeException -- a
|
||||
// crash on every alarm, i.e. enforcement silently stops. Bailing
|
||||
// out costs one tick instead; the alarm chain is re-armed below.
|
||||
Log.e(FocusDeviceAdminReceiver.TAG, "could not enter foreground; skipping pass")
|
||||
EnforcementScheduler(applicationContext).scheduleNext()
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Run off the main thread. The pass now waits up to
|
||||
// EnforcementRunner.ACQUIRE_TIMEOUT_MS for a location fix, and
|
||||
// blocking the main thread for that long is an ANR. The foreground
|
||||
// notification is already showing, so the service is entitled to stay
|
||||
// alive for the few seconds this takes.
|
||||
worker.execute {
|
||||
runCatching { applyAndReschedule() }
|
||||
.onFailure {
|
||||
Log.e(FocusDeviceAdminReceiver.TAG, "enforcement run failed", it)
|
||||
}
|
||||
// Stopped from the completion path, not from onStartCommand: doing
|
||||
// it there would tear the service down while the work is in flight.
|
||||
stopSelf(startId)
|
||||
}
|
||||
// The alarm brings us back. START_NOT_STICKY because a restart by the
|
||||
// system would be a duplicate of the next alarm.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
worker.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Enters the foreground with [type], or returns false if refused. */
|
||||
private fun startForegroundTyped(type: Int): Boolean = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(), type)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "FGS type $type refused", error)
|
||||
false
|
||||
}
|
||||
|
||||
private fun applyAndReschedule() {
|
||||
val context = applicationContext
|
||||
// Scheduled first, in a finally, so that no failure below can end the
|
||||
// chain: an enforcement bug should cost one tick, not stop the system.
|
||||
// Genuinely scheduled FIRST, not merely in a finally. A finally covers
|
||||
// exceptions but not process death, and the pass now blocks up to
|
||||
// ACQUIRE_TIMEOUT_MS waiting for a fix -- a kill inside that window
|
||||
// (low memory, force-stop) would lose the alarm chain entirely, with
|
||||
// no way back until the app is opened or the phone reboots. Arming up
|
||||
// front costs nothing: the next pass re-arms again regardless.
|
||||
EnforcementScheduler(context).scheduleNext()
|
||||
try {
|
||||
val bridge = DevicePolicyBridge(context)
|
||||
if (!bridge.isDeviceOwner() && !bridge.isProfileOwner()) {
|
||||
// Not provisioned: there is nothing this build may enforce, and
|
||||
// saying so is more useful than failing silently every tick.
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "not DO/PO - no enforcement applied")
|
||||
recordFailure(context, "not device owner or profile owner")
|
||||
return
|
||||
}
|
||||
val decision = EnforcementRunner(context).decide()
|
||||
// Granted before deciding, not only in apply(). decide() is what
|
||||
// reads the location, so granting afterwards left the very first
|
||||
// pass after an install with no permission at all -- outcome
|
||||
// NO_PERMISSION, reason LOCATION_UNKNOWN, everything hidden for a
|
||||
// full cadence. Cheap and idempotent, so it also stays in apply()
|
||||
// where it self-heals every pass.
|
||||
bridge.grantLocationPermissions()
|
||||
bridge.enableLocation()
|
||||
|
||||
// One runner for both calls: it carries the acquired fix and the
|
||||
// pass timings from decide() through to the record apply() writes.
|
||||
// Constructing a second one silently discarded all of that.
|
||||
val runner = EnforcementRunner(context)
|
||||
val decision = runner.decide()
|
||||
if (decision == null) {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "no decision - policy unreadable")
|
||||
recordFailure(context, "policy unreadable - no decision made")
|
||||
return
|
||||
}
|
||||
EnforcementRunner(context).apply(decision, bridge)
|
||||
runner.apply(decision, bridge)
|
||||
} finally {
|
||||
// Re-armed, not redundant with the pre-arm above: scheduleNext
|
||||
// picks the sooner of the fallback and the next curfew boundary,
|
||||
// and the pass itself can take 20 s, so re-running it here lands
|
||||
// on the correct boundary rather than one computed before the
|
||||
// pass. Re-arming an already-armed alarm just replaces it.
|
||||
EnforcementScheduler(context).scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaves a trace for a pass that produced no decision. */
|
||||
private fun recordFailure(context: Context, failure: String) {
|
||||
EnforcementLog(context).append(
|
||||
EnforcementLog.failureRecord(System.currentTimeMillis(), failure),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@ -99,7 +183,14 @@ class EnforcementService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Foreground service type, kept next to the manifest declaration. */
|
||||
const val FGS_TYPE: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
/**
|
||||
* Foreground service type, mirroring the manifest declaration.
|
||||
*
|
||||
* LOCATION is part of it because the pass requests a fix; without that
|
||||
* type, background location delivery is throttled hard enough that the
|
||||
* request usually times out and the pass fails closed.
|
||||
*/
|
||||
const val FGS_TYPE: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,14 +50,33 @@ data class HomeLocation(
|
||||
* rather than a call into it. A background [android.app.Service] has no Flutter
|
||||
* engine, and starting one per alarm to answer a pure question would be both
|
||||
* slow and a new failure mode at exactly the moment enforcement must be
|
||||
* reliable. `PolicyParityTest` pins the two against shared fixtures so they
|
||||
* cannot drift.
|
||||
* reliable.
|
||||
*
|
||||
* This side is the one that enforces. The Dart copy is used only to display
|
||||
* the policy (allowlist sizes, the curfew window), never to decide anything --
|
||||
* a `PolicyParityTest` was cited here for months and did not exist, and the
|
||||
* two had drifted apart unnoticed. Keeping the Dart side out of the decision
|
||||
* path means a drift can no longer produce a wrong answer, only a stale label.
|
||||
*/
|
||||
data class FocusPolicy(
|
||||
val home: HomeLocation,
|
||||
val allowedPackages: Set<String>,
|
||||
val nightAllowedPackages: Set<String>,
|
||||
val neverDisablePrefixes: Set<String>,
|
||||
/**
|
||||
* Prefix-matched day allowlist, for apps shipping as a family of packages.
|
||||
*
|
||||
* Tachiyomi installs every source as its own apk, so an exact list goes
|
||||
* stale the moment a new extension is installed -- which from the phone is
|
||||
* indistinguishable from the enforcer being broken. Matched on whole
|
||||
* labels like [neverDisablePrefixes], not as a bare string prefix.
|
||||
*
|
||||
* Weaker than the exact list by construction: it allows packages that do
|
||||
* not exist yet, so entries must stay narrow and vendor-specific.
|
||||
*/
|
||||
val allowedPrefixes: Set<String> = emptySet(),
|
||||
/** Prefixes that survive the curfew. Subset of [allowedPrefixes]. */
|
||||
val nightAllowedPrefixes: Set<String> = emptySet(),
|
||||
val workoutUnblockDomains: Set<String>,
|
||||
val curfew: CurfewWindow?,
|
||||
val launcherPackage: String?,
|
||||
@ -111,16 +130,17 @@ data class FocusPolicy(
|
||||
* Prefix-matched on whole labels, so `com.android.providers` covers
|
||||
* `com.android.providers.telephony` but not `com.android.providersomething`.
|
||||
*/
|
||||
fun isProtected(packageName: String): Boolean = neverDisablePrefixes.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
fun isProtected(packageName: String): Boolean =
|
||||
matchesPrefix(packageName, neverDisablePrefixes)
|
||||
|
||||
/** Whether a package may run under the given conditions. */
|
||||
fun isAllowed(packageName: String, duringCurfew: Boolean): Boolean {
|
||||
if (isProtected(packageName)) return true
|
||||
if (packageName == launcherPackage) return true
|
||||
val allowed = if (duringCurfew) nightAllowedPackages else allowedPackages
|
||||
return packageName in allowed
|
||||
if (packageName in allowed) return true
|
||||
val prefixes = if (duringCurfew) nightAllowedPrefixes else allowedPrefixes
|
||||
return matchesPrefix(packageName, prefixes)
|
||||
}
|
||||
|
||||
/** Whether the curfew is in force at [minutesSinceMidnight]. */
|
||||
@ -153,6 +173,17 @@ data class FocusPolicy(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Whether [packageName] is covered by any entry of [prefixes].
|
||||
*
|
||||
* Matched on whole labels, so `com.android.providers` covers
|
||||
* `com.android.providers.telephony` but not
|
||||
* `com.android.providersomething`. Shared by every prefix list so the
|
||||
* boundary rule cannot drift between them.
|
||||
*/
|
||||
private fun matchesPrefix(packageName: String, prefixes: Set<String>): Boolean =
|
||||
prefixes.any { packageName == it || packageName.startsWith("$it.") }
|
||||
|
||||
/** Reads and parses the policy bundled as an asset. */
|
||||
fun load(context: Context, assetName: String = "flutter_assets/assets/policy.json"): FocusPolicy =
|
||||
context.assets.open(assetName).bufferedReader().use { parse(it.readText()) }
|
||||
@ -185,6 +216,8 @@ data class FocusPolicy(
|
||||
allowedPackages = json.stringSet("allowed_packages"),
|
||||
nightAllowedPackages = json.stringSet("night_allowed_packages"),
|
||||
neverDisablePrefixes = json.stringSet("never_disable_prefixes"),
|
||||
allowedPrefixes = json.optionalStringSet("allowed_prefixes"),
|
||||
nightAllowedPrefixes = json.optionalStringSet("night_allowed_prefixes"),
|
||||
workoutUnblockDomains = json.stringSet("workout_unblock_domains"),
|
||||
blockableSystemPackages = json.optionalStringSet("blockable_system_packages"),
|
||||
alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"),
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
/** How the current fix was obtained, or why there is none. */
|
||||
enum class FixOutcome {
|
||||
/** A cached fix younger than the freshness window; no request was made. */
|
||||
CACHED_FRESH,
|
||||
|
||||
/** The platform answered a request made during this pass. */
|
||||
ACTIVE_OK,
|
||||
|
||||
/** The request did not answer in time; any fix here is the stale cache. */
|
||||
TIMEOUT,
|
||||
|
||||
/** Neither fine nor coarse location is granted. */
|
||||
NO_PERMISSION,
|
||||
|
||||
/** No location provider is enabled, or the service is missing. */
|
||||
NO_PROVIDER,
|
||||
}
|
||||
|
||||
/**
|
||||
* A location fix plus everything needed to judge and explain it.
|
||||
*
|
||||
* Age and accuracy are carried rather than discarded because they are what
|
||||
* separates a trustworthy classification from a guess: the geofence is 150 m
|
||||
* wide, so a fix that is an hour old or accurate to 2 km cannot answer the
|
||||
* question being asked of it.
|
||||
*
|
||||
* Coordinates never leave this object -- they are used to compute a distance
|
||||
* and are deliberately absent from the log record and the UI, since logcat is
|
||||
* readable by anyone with adb and this is the user's home address.
|
||||
*/
|
||||
data class LocationFix(
|
||||
val latitude: Double?,
|
||||
val longitude: Double?,
|
||||
/** Milliseconds since the fix was taken, from the monotonic clock. */
|
||||
val ageMs: Long,
|
||||
/** Reported accuracy radius in metres, or null when unknown. */
|
||||
val accuracyM: Double?,
|
||||
/** Provider name (`gps`, `fused`, `network`), or null when there is no fix. */
|
||||
val provider: String?,
|
||||
val outcome: FixOutcome,
|
||||
) {
|
||||
/** Whether there are usable coordinates at all. */
|
||||
val hasCoordinates: Boolean get() = latitude != null && longitude != null
|
||||
|
||||
/**
|
||||
* Whether this fix is recent enough to describe where the phone is now.
|
||||
*
|
||||
* The window must exceed the enforcement cadence. A shorter one would mark
|
||||
* the newest available cache stale on every pass of an idle phone, sending
|
||||
* every pass to LOCATION_UNKNOWN -- which blocks everything, the exact
|
||||
* failure this rewrite exists to remove.
|
||||
*/
|
||||
fun isFresh(windowMs: Long): Boolean = hasCoordinates && ageMs <= windowMs
|
||||
|
||||
companion object {
|
||||
/** A fix that could not be obtained. */
|
||||
fun unavailable(outcome: FixOutcome): LocationFix = LocationFix(
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
ageMs = Long.MAX_VALUE,
|
||||
accuracyM = null,
|
||||
provider = null,
|
||||
outcome = outcome,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,14 @@ import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
/** For channel calls that block; the platform thread must stay free. */
|
||||
private val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
|
||||
override fun onDestroy() {
|
||||
worker.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val bridge = DevicePolicyBridge(applicationContext)
|
||||
@ -14,11 +22,22 @@ class MainActivity : FlutterActivity() {
|
||||
when (call.method) {
|
||||
"status" -> result.success(bridge.status())
|
||||
"releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
|
||||
"setHomeToCurrentLocation" ->
|
||||
result.success(
|
||||
EnforcementRunner(applicationContext)
|
||||
.setHomeToCurrentLocation(),
|
||||
)
|
||||
// Off the platform thread: setting home now waits for a
|
||||
// genuinely fresh fix (up to ACQUIRE_TIMEOUT_MS), and
|
||||
// blocking the main thread for that long is an ANR on the
|
||||
// one button that provisions the geofence.
|
||||
"setHomeToCurrentLocation" -> worker.execute {
|
||||
val outcome = runCatching {
|
||||
EnforcementRunner(applicationContext).setHomeToCurrentLocation()
|
||||
}.getOrElse { it.message ?: it::class.java.simpleName }
|
||||
// The work outlives a shutdown() (it only stops new
|
||||
// submissions), so the activity can be gone by now.
|
||||
// Replying to a dead activity's channel throws, and
|
||||
// the home write has already happened either way.
|
||||
if (!isDestroyed && !isFinishing) {
|
||||
runOnUiThread { result.success(outcome) }
|
||||
}
|
||||
}
|
||||
"hasHomeLocation" ->
|
||||
result.success(
|
||||
EnforcementRunner(applicationContext).hasHomeLocation(),
|
||||
@ -77,6 +96,16 @@ class MainActivity : FlutterActivity() {
|
||||
EnforcementScheduler(applicationContext).cancel()
|
||||
result.success(true)
|
||||
}
|
||||
// Returned as raw JSON strings rather than platform maps:
|
||||
// the channel codec stays trivial and the Dart side owns
|
||||
// one well-defined parse, so the record format and the UI
|
||||
// can be versioned together.
|
||||
"readEnforcementLog" -> {
|
||||
val limit = call.argument<Int>("limit") ?: 200
|
||||
result.success(
|
||||
EnforcementLog(applicationContext).readRecent(limit),
|
||||
)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,4 +267,152 @@ class EnforcementDecisionTest {
|
||||
assertTrue(p.isProtected("com.android.settings.sub"))
|
||||
assertFalse(p.isProtected("com.android.settingsomething"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distance and threshold are reported so the phone can explain itself`() {
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
|
||||
)
|
||||
// ~5 km away, and the value it was actually compared against.
|
||||
assertEquals(5000.0, decision.distanceM!!, 50.0)
|
||||
assertEquals(150.0, decision.thresholdM!!, 0.001)
|
||||
assertEquals(false, decision.insideFence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hysteresis widens the threshold only for a phone already inside`() {
|
||||
// 160 m out: outside the bare 150 m radius, inside 150 + 30. This is
|
||||
// what stops a phone parked on the boundary from flapping.
|
||||
val outside = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(
|
||||
installed,
|
||||
12 * 60,
|
||||
latOffset(160.0),
|
||||
homeLon,
|
||||
currentlyEnforcing = false,
|
||||
),
|
||||
)
|
||||
assertEquals(150.0, outside.thresholdM!!, 0.001)
|
||||
assertEquals(EnforcementReason.AWAY, outside.reason)
|
||||
|
||||
val inside = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(
|
||||
installed,
|
||||
12 * 60,
|
||||
latOffset(160.0),
|
||||
homeLon,
|
||||
currentlyEnforcing = true,
|
||||
),
|
||||
)
|
||||
assertEquals(180.0, inside.thresholdM!!, 0.001)
|
||||
assertEquals(EnforcementReason.AT_HOME, inside.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unanswerable fence reports null rather than outside`() {
|
||||
// No fix at all. Recording "outside" here would drop hysteresis on the
|
||||
// next pass at exactly the moment the input is least reliable.
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(installed, 12 * 60, latitude = null, longitude = null),
|
||||
)
|
||||
assertEquals(EnforcementReason.LOCATION_UNKNOWN, decision.reason)
|
||||
assertEquals(null, decision.insideFence)
|
||||
assertEquals(null, decision.distanceM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no home coordinates reports an unknown distance, not zero`() {
|
||||
// Fabricating 0 m would read on screen as "you are exactly at home".
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policy(withCoordinates = false),
|
||||
EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
|
||||
)
|
||||
assertEquals(null, decision.distanceM)
|
||||
assertEquals(true, decision.insideFence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every hidden package carries a reason`() {
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
|
||||
)
|
||||
// The UI lists these per package, so a gap would render as a blank
|
||||
// explanation next to a hidden app.
|
||||
assertEquals(decision.packagesToHide, decision.hideReasons.keys)
|
||||
assertTrue(
|
||||
decision.hideReasons.values.all { it == HideReason.NOT_IN_ALLOWLIST },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `curfew hiding is attributed to the night list`() {
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policy(),
|
||||
EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon),
|
||||
)
|
||||
assertEquals(EnforcementReason.CURFEW, decision.reason)
|
||||
// com.discord is day-allowed but not night-allowed, so the distinction
|
||||
// matters: "not in allowlist" would be misleading.
|
||||
assertEquals(
|
||||
HideReason.NOT_IN_NIGHT_ALLOWLIST,
|
||||
decision.hideReasons["com.discord"],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always-blocked is attributed as such even when away`() {
|
||||
val decision = EnforcementDecision.evaluate(
|
||||
policyWithAlwaysBlocked(),
|
||||
EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
|
||||
)
|
||||
assertEquals(EnforcementReason.AWAY, decision.reason)
|
||||
assertEquals(
|
||||
HideReason.ALWAYS_BLOCKED,
|
||||
decision.hideReasons["com.google.android.youtube"],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `allowed prefixes cover a family of packages`() {
|
||||
val p = FocusPolicy.parse(
|
||||
"""
|
||||
{
|
||||
"schema_version": 1,
|
||||
"home": {
|
||||
"latitude": $homeLat, "longitude": $homeLon,
|
||||
"radius_m": 150.0, "hysteresis_m": 30.0
|
||||
},
|
||||
"curfew": {"start":"23:00","end":"05:00"},
|
||||
"launcher_package": "com.launcher",
|
||||
"allowed_packages": ["com.launcher"],
|
||||
"night_allowed_packages": ["com.launcher"],
|
||||
"never_disable_prefixes": [],
|
||||
"workout_unblock_domains": [],
|
||||
"allowed_prefixes": ["eu.kanade.tachiyomi"],
|
||||
"night_allowed_prefixes": ["eu.kanade.tachiyomi"],
|
||||
"browser_packages": []
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", false))
|
||||
assertTrue(p.isAllowed("eu.kanade.tachiyomi.extension.all.mangadex", false))
|
||||
// Chosen 2026-08-14: manga stays available during the curfew.
|
||||
assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", true))
|
||||
// Whole labels only.
|
||||
assertFalse(p.isAllowed("eu.kanade.tachiyomisomething", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an asset without prefix keys still parses`() {
|
||||
// Back-compat: a policy rendered before prefixes existed must degrade
|
||||
// to exact matching rather than failing to parse at all, since an
|
||||
// unparsable policy skips the pass entirely.
|
||||
assertTrue(policy().allowedPrefixes.isEmpty())
|
||||
assertTrue(policy().nightAllowedPrefixes.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,251 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/**
|
||||
* The log is the only way this app can explain itself after the fact.
|
||||
*
|
||||
* `run-as` is refused on the release build device owner requires, and logcat
|
||||
* rotates -- measured empty while 82 alarms had fired. So these tests cover
|
||||
* the two properties that matter: a record survives to be read back, and no
|
||||
* coordinate ever reaches it.
|
||||
*/
|
||||
class EnforcementLogTest {
|
||||
|
||||
@get:Rule
|
||||
val folder = TemporaryFolder()
|
||||
|
||||
private val homeLat = 52.2297
|
||||
private val homeLon = 21.0122
|
||||
|
||||
private fun log() = EnforcementLog(folder.root)
|
||||
|
||||
private fun decision(
|
||||
reason: EnforcementReason = EnforcementReason.AT_HOME,
|
||||
distanceM: Double? = 42.0,
|
||||
) = EnforcementDecision(
|
||||
reason = reason,
|
||||
packagesToHide = setOf("com.google.android.youtube"),
|
||||
packagesToShow = setOf("com.launcher"),
|
||||
distanceM = distanceM,
|
||||
thresholdM = 180.0,
|
||||
insideFence = true,
|
||||
hideReasons = mapOf("com.google.android.youtube" to HideReason.ALWAYS_BLOCKED),
|
||||
)
|
||||
|
||||
private fun fix() = LocationFix(
|
||||
latitude = homeLat,
|
||||
longitude = homeLon,
|
||||
ageMs = 45_000L,
|
||||
accuracyM = 12.0,
|
||||
provider = "gps",
|
||||
outcome = FixOutcome.ACTIVE_OK,
|
||||
)
|
||||
|
||||
private fun record(
|
||||
decision: EnforcementDecision = decision(),
|
||||
fix: LocationFix? = fix(),
|
||||
) = EnforcementLog.record(
|
||||
timestampMs = 1_786_000_000_000L,
|
||||
decision = decision,
|
||||
fix = fix,
|
||||
policy = null,
|
||||
homeConfigured = true,
|
||||
permissions = setOf("android.permission.ACCESS_FINE_LOCATION"),
|
||||
hidDelta = listOf("com.google.android.youtube"),
|
||||
restoredDelta = emptyList(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a record round-trips through the file`() {
|
||||
log().append(record())
|
||||
val lines = log().readRecent()
|
||||
assertEquals(1, lines.size)
|
||||
val parsed = JSONObject(lines.first())
|
||||
assertEquals("AT_HOME", parsed.getString("reason"))
|
||||
assertEquals(42.0, parsed.getDouble("distance_m"), 0.001)
|
||||
assertEquals(180.0, parsed.getDouble("threshold_m"), 0.001)
|
||||
assertEquals("gps", parsed.getJSONObject("fix").getString("provider"))
|
||||
assertEquals("ACTIVE_OK", parsed.getJSONObject("fix").getString("outcome"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the record never contains coordinates`() {
|
||||
// The single most important property here: logcat and this file are
|
||||
// both reachable by anyone with the phone, and this is a home address.
|
||||
val serialised = record().toString()
|
||||
assertFalse(serialised.contains("52.2"))
|
||||
assertFalse(serialised.contains("21.0"))
|
||||
assertFalse(serialised.contains("latitude"))
|
||||
assertFalse(serialised.contains("longitude"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every hidden package carries why it is hidden`() {
|
||||
val parsed = JSONObject(record().toString())
|
||||
val hidden = parsed.getJSONArray("hidden")
|
||||
assertEquals(1, hidden.length())
|
||||
assertEquals("com.google.android.youtube", hidden.getJSONObject(0).getString("pkg"))
|
||||
assertEquals("ALWAYS_BLOCKED", hidden.getJSONObject(0).getString("why"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records read back newest first`() {
|
||||
val subject = log()
|
||||
subject.append(record(decision(reason = EnforcementReason.AWAY)))
|
||||
subject.append(record(decision(reason = EnforcementReason.CURFEW)))
|
||||
val lines = subject.readRecent()
|
||||
assertEquals("CURFEW", JSONObject(lines[0]).getString("reason"))
|
||||
assertEquals("AWAY", JSONObject(lines[1]).getString("reason"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readRecent respects the limit`() {
|
||||
val subject = log()
|
||||
repeat(5) { subject.append(record()) }
|
||||
assertEquals(2, subject.readRecent(limit = 2).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt line is skipped rather than losing the history`() {
|
||||
// A process killed mid-append must not cost the whole log, which is
|
||||
// exactly when the log is most wanted.
|
||||
val subject = log()
|
||||
subject.append(record())
|
||||
java.io.File(folder.root, "enforcement_log.jsonl").appendText("{half-written\n")
|
||||
subject.append(record(decision(reason = EnforcementReason.AWAY)))
|
||||
val lines = subject.readRecent()
|
||||
assertEquals(2, lines.size)
|
||||
assertEquals("AWAY", JSONObject(lines[0]).getString("reason"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty log reads as empty rather than failing`() {
|
||||
assertTrue(log().readRecent().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the file is capped and rotates on whole lines`() {
|
||||
val subject = log()
|
||||
// Enough records to trigger several rotations.
|
||||
repeat(1200) { subject.append(record()) }
|
||||
val file = java.io.File(folder.root, "enforcement_log.jsonl")
|
||||
val lines = file.readLines().filter { it.isNotBlank() }
|
||||
// Growth is bounded, but by neither constant alone: a trim drops the
|
||||
// file to KEEP_LINES (400) and appends then resume until MAX_BYTES is
|
||||
// next crossed, so the steady state is 400 plus one trim interval.
|
||||
// What matters is that 1200 appends do not accumulate.
|
||||
assertTrue("expected rotation, got ${lines.size} lines", lines.size < 1200)
|
||||
assertTrue("expected a bounded file, got ${lines.size}", lines.size <= 800)
|
||||
assertTrue(lines.isNotEmpty())
|
||||
// Rotating mid-record would make the oldest survivor unparsable.
|
||||
lines.forEach { JSONObject(it) }
|
||||
}
|
||||
|
||||
private fun candidate(ageMs: Long, accuracyM: Double?, provider: String) =
|
||||
LocationFix(
|
||||
latitude = homeLat,
|
||||
longitude = homeLon,
|
||||
ageMs = ageMs,
|
||||
accuracyM = accuracyM,
|
||||
provider = provider,
|
||||
outcome = FixOutcome.CACHED_FRESH,
|
||||
)
|
||||
|
||||
private val window = 30L * 60L * 1000L
|
||||
|
||||
@Test
|
||||
fun `a recent coarse fix beats an old precise one`() {
|
||||
// The ordering that matters. Sorting by accuracy first would surface
|
||||
// the morning GPS fix, the age gate would then reject it, the active
|
||||
// request times out indoors, and the pass falls to LOCATION_UNKNOWN
|
||||
// and blocks everything -- the office symptom, reintroduced.
|
||||
val chosen = EnforcementRunner.best(
|
||||
listOf(
|
||||
candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"),
|
||||
candidate(ageMs = 2L * 60 * 1000, accuracyM = 500.0, provider = "network"),
|
||||
),
|
||||
window,
|
||||
)
|
||||
assertEquals("network", chosen?.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `within the window the more accurate fix wins`() {
|
||||
// A 2 km fix cannot answer a 150 m question, so a slightly older but
|
||||
// far more precise fix is the better input.
|
||||
val chosen = EnforcementRunner.best(
|
||||
listOf(
|
||||
candidate(ageMs = 10_000, accuracyM = 2000.0, provider = "network"),
|
||||
candidate(ageMs = 60_000, accuracyM = 10.0, provider = "gps"),
|
||||
),
|
||||
window,
|
||||
)
|
||||
assertEquals("gps", chosen?.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown accuracy loses to a known one`() {
|
||||
val chosen = EnforcementRunner.best(
|
||||
listOf(
|
||||
candidate(ageMs = 10_000, accuracyM = null, provider = "passive"),
|
||||
candidate(ageMs = 60_000, accuracyM = 80.0, provider = "gps"),
|
||||
),
|
||||
window,
|
||||
)
|
||||
assertEquals("gps", chosen?.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing fresh the newest stale fix is reported`() {
|
||||
// The caller classifies it unusable regardless; returning the newest
|
||||
// makes the log record describe what was actually available.
|
||||
val chosen = EnforcementRunner.best(
|
||||
listOf(
|
||||
candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"),
|
||||
candidate(ageMs = 40L * 60 * 1000, accuracyM = 500.0, provider = "network"),
|
||||
),
|
||||
window,
|
||||
)
|
||||
assertEquals("network", chosen?.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no candidates yields no fix`() {
|
||||
assertEquals(null, EnforcementRunner.best(emptyList(), window))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single fix is returned even with unknown accuracy`() {
|
||||
val chosen = EnforcementRunner.best(
|
||||
listOf(candidate(ageMs = 10_000, accuracyM = null, provider = "passive")),
|
||||
window,
|
||||
)
|
||||
assertEquals("passive", chosen?.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pass with no fix records the outcome rather than a bare null`() {
|
||||
val parsed = JSONObject(
|
||||
record(
|
||||
decision = decision(
|
||||
reason = EnforcementReason.LOCATION_UNKNOWN,
|
||||
distanceM = null,
|
||||
),
|
||||
fix = LocationFix.unavailable(FixOutcome.TIMEOUT),
|
||||
).toString(),
|
||||
)
|
||||
assertEquals("LOCATION_UNKNOWN", parsed.getString("reason"))
|
||||
assertTrue(parsed.isNull("distance_m"))
|
||||
// This is the field that separates "at the office, cache empty" from
|
||||
// "fuzzed fix at home" -- the two symptoms that looked identical.
|
||||
assertEquals("TIMEOUT", parsed.getJSONObject("fix").getString("outcome"))
|
||||
assertTrue(parsed.getJSONObject("fix").isNull("age_ms"))
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"allowed_packages": [
|
||||
"com.android.vending",
|
||||
"com.celzero.bravedns",
|
||||
"com.facebook.orca",
|
||||
"com.google.android.apps.maps",
|
||||
@ -29,6 +30,9 @@
|
||||
"pl.mbank",
|
||||
"pl.nask.mobywatel"
|
||||
],
|
||||
"allowed_prefixes": [
|
||||
"eu.kanade.tachiyomi"
|
||||
],
|
||||
"always_blocked_packages": [
|
||||
"com.android.chrome",
|
||||
"com.google.android.apps.youtube.music",
|
||||
@ -122,6 +126,9 @@
|
||||
"pl.mbank",
|
||||
"pl.nask.mobywatel"
|
||||
],
|
||||
"night_allowed_prefixes": [
|
||||
"eu.kanade.tachiyomi"
|
||||
],
|
||||
"private_dns_host": "",
|
||||
"schema_version": 1,
|
||||
"vpn_lockdown": true,
|
||||
|
||||
191
focus_owner/lib/debug_log_page.dart
Normal file
191
focus_owner/lib/debug_log_page.dart
Normal file
@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:focus_owner/enforcement_record.dart';
|
||||
import 'package:focus_owner/theme.dart';
|
||||
|
||||
/// The enforcement history, newest first.
|
||||
///
|
||||
/// This screen is the deliverable for "extensive debug log to see when it does
|
||||
/// not work". It has to live in the app: logcat rotates away within minutes
|
||||
/// (measured empty while `dumpsys alarm` reported 82 alarms had fired) and
|
||||
/// `run-as` is refused on the release build device owner requires, so neither
|
||||
/// adb route can read the history.
|
||||
class DebugLogPage extends StatelessWidget {
|
||||
const DebugLogPage({required this.records, super.key});
|
||||
|
||||
final List<EnforcementRecord> records;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: kField,
|
||||
appBar: AppBar(
|
||||
backgroundColor: kField,
|
||||
foregroundColor: kText,
|
||||
title: const Text('Enforcement log'),
|
||||
),
|
||||
body: records.isEmpty
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(kGap * 2),
|
||||
child: Text(
|
||||
'No passes recorded yet.\n\n'
|
||||
'Each enforcement pass appends one entry here, so this fills '
|
||||
'up on its own every 15 minutes.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: kMuted, height: 1.4),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(kGap),
|
||||
itemCount: records.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) => _Entry(record: records[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Entry extends StatelessWidget {
|
||||
const _Entry({required this.record});
|
||||
|
||||
final EnforcementRecord record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Material, not a decorated box: ExpansionTile paints its ink on the
|
||||
// nearest Material ancestor and a DecoratedBox in between hides it.
|
||||
return Material(
|
||||
color: kSurface,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
_clock(record.timestamp),
|
||||
style: const TextStyle(color: kMuted, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: kGap / 2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
record.failure != null
|
||||
? 'NO DECISION'
|
||||
: record.reason.replaceAll('_', ' '),
|
||||
style: TextStyle(
|
||||
color: record.failure != null || record.locationUnknown
|
||||
? kDanger
|
||||
: kText,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
record.distanceLabel,
|
||||
style: const TextStyle(color: kMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kGap, 0, kGap, kGap),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_line('When', record.timestamp.toString()),
|
||||
_line('Fix', record.fixLabel),
|
||||
_line(
|
||||
'Curfew',
|
||||
switch (record.curfewActive) {
|
||||
true => 'active',
|
||||
false => 'inactive',
|
||||
null => 'unknown',
|
||||
},
|
||||
),
|
||||
_line('Home set', '${record.homeConfigured ?? "unknown"}'),
|
||||
_line('Inside fence', '${record.insideFence ?? "unanswerable"}'),
|
||||
_line(
|
||||
'Counts',
|
||||
'${record.hideCount} hidden / ${record.showCount} shown · '
|
||||
'hid ${record.hidNow}, restored ${record.restoredNow}',
|
||||
),
|
||||
if (record.failure != null)
|
||||
_line('Failure', record.failure!, danger: true),
|
||||
if (record.hidden.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Hidden',
|
||||
style: TextStyle(color: kMuted, fontSize: 12),
|
||||
),
|
||||
for (final h in record.hidden)
|
||||
Text(
|
||||
' ${h.package} — ${h.reason.explanation}',
|
||||
style: const TextStyle(color: kText, fontSize: 11),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// Lets a record leave the phone without adb, which is the
|
||||
// only other way it could be shared.
|
||||
TextButton(
|
||||
onPressed: () => Clipboard.setData(
|
||||
ClipboardData(text: _asText(record)),
|
||||
),
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _line(String label, String value, {bool danger = false}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: kMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: danger ? kDanger : kText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
static String _clock(DateTime at) =>
|
||||
'${at.hour.toString().padLeft(2, '0')}:'
|
||||
'${at.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
static String _asText(EnforcementRecord r) => [
|
||||
'when: ${r.timestamp}',
|
||||
'reason: ${r.reason}',
|
||||
'distance: ${r.distanceLabel}',
|
||||
'fix: ${r.fixLabel}',
|
||||
'curfew active: ${r.curfewActive}',
|
||||
'home configured: ${r.homeConfigured}',
|
||||
'inside fence: ${r.insideFence}',
|
||||
'hidden: ${r.hideCount}, shown: ${r.showCount}',
|
||||
'hid now: ${r.hidNow}, restored now: ${r.restoredNow}',
|
||||
if (r.failure != null) 'failure: ${r.failure}',
|
||||
for (final h in r.hidden) ' ${h.package} (${h.reason.explanation})',
|
||||
].join('\n');
|
||||
}
|
||||
@ -8,6 +8,7 @@ class DevicePolicyStatus {
|
||||
required this.isAdminActive,
|
||||
required this.sdkInt,
|
||||
required this.restrictionsApplied,
|
||||
this.hasAccounts = true,
|
||||
});
|
||||
|
||||
/// Builds a status from the raw platform-channel map.
|
||||
@ -18,6 +19,8 @@ class DevicePolicyStatus {
|
||||
isAdminActive: map['isAdminActive']! as bool,
|
||||
sdkInt: map['sdkInt']! as int,
|
||||
restrictionsApplied: map['restrictionsApplied']! as bool,
|
||||
// Absent from an older native side; default to the scarier reading.
|
||||
hasAccounts: map['hasAccounts'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,9 +31,20 @@ class DevicePolicyStatus {
|
||||
|
||||
/// Whether any user restriction is currently enforced.
|
||||
///
|
||||
/// Always false in this build: the app is provisioning-capable but inert
|
||||
/// until the release path has been verified on a real device.
|
||||
/// Hardcoded false by the native side and NOT a statement about enforcement
|
||||
/// — the app is live and hiding packages, and it does apply user
|
||||
/// restrictions (`DISALLOW_CONFIG_VPN`, `DISALLOW_CONFIG_PRIVATE_DNS`) once
|
||||
/// those layers are locked. Nothing reads this back from the system yet, so
|
||||
/// treat it as "not reported" rather than "nothing applied"; the honest
|
||||
/// enforcement state is the hidden-app count in the latest
|
||||
/// [EnforcementRecord], which is what the status screen shows.
|
||||
final bool restrictionsApplied;
|
||||
|
||||
/// Whether any account exists, which decides if releasing is reversible.
|
||||
///
|
||||
/// `dpm set-device-owner` refuses while accounts exist, so once one is
|
||||
/// added, releasing can only be undone by a factory reset.
|
||||
final bool hasAccounts;
|
||||
}
|
||||
|
||||
/// Dart side of the device-policy platform channel.
|
||||
@ -61,6 +75,20 @@ class DevicePolicy {
|
||||
Future<bool> cancelEnforcement() async =>
|
||||
await channel.invokeMethod<bool>('cancelEnforcement') ?? false;
|
||||
|
||||
/// Reads the durable enforcement log, newest first.
|
||||
///
|
||||
/// This is the only way to see what the enforcer has been doing: logcat
|
||||
/// rotates (measured empty while 82 alarms had fired) and `run-as` is
|
||||
/// refused on the release build device owner requires, so neither adb route
|
||||
/// works. Returns raw JSON lines for [EnforcementRecord.parseLines].
|
||||
Future<List<String>> readEnforcementLog({int limit = 200}) async {
|
||||
final lines = await channel.invokeMethod<List<Object?>>(
|
||||
'readEnforcementLog',
|
||||
{'limit': limit},
|
||||
);
|
||||
return lines?.whereType<String>().toList() ?? const [];
|
||||
}
|
||||
|
||||
/// Relinquishes device ownership without wiping the device.
|
||||
///
|
||||
/// This is the escape hatch. It is deliberately reachable from the app's
|
||||
|
||||
@ -1,192 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:focus_owner/policy.dart';
|
||||
|
||||
/// Why enforcement is currently on or off.
|
||||
///
|
||||
/// Carried alongside the decision so the UI and the logs can say *why* an app
|
||||
/// is hidden. "Blocked" with no reason is the kind of thing that makes a
|
||||
/// wellbeing tool feel broken rather than deliberate.
|
||||
enum EnforcementReason {
|
||||
/// Away from home, so nothing is enforced.
|
||||
away,
|
||||
|
||||
/// At home during the day: the full allowlist applies.
|
||||
atHome,
|
||||
|
||||
/// Inside the curfew window: the much shorter night allowlist applies.
|
||||
curfew,
|
||||
|
||||
/// A workout is in progress, so the workout exceptions are released.
|
||||
workout,
|
||||
|
||||
/// Location is unknown. Treated as "at home" deliberately — see
|
||||
/// [EnforcementDecision.evaluate].
|
||||
locationUnknown,
|
||||
}
|
||||
|
||||
/// Inputs to one enforcement decision.
|
||||
///
|
||||
/// Grouped into a value type so the decision is a pure function of its inputs
|
||||
/// and can be tested without a device, a clock, or a location provider.
|
||||
class EnforcementInputs {
|
||||
const EnforcementInputs({
|
||||
required this.installedPackages,
|
||||
required this.minutesSinceMidnight,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.currentlyEnforcing = false,
|
||||
this.workoutActive = false,
|
||||
});
|
||||
|
||||
/// Third-party packages present on the device.
|
||||
final Set<String> installedPackages;
|
||||
|
||||
/// Local time, as minutes since midnight.
|
||||
final int minutesSinceMidnight;
|
||||
|
||||
/// Current position, or null when location is unavailable.
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
/// Whether enforcement is already active, for geofence hysteresis.
|
||||
final bool currentlyEnforcing;
|
||||
|
||||
/// Whether a workout is in progress.
|
||||
final bool workoutActive;
|
||||
}
|
||||
|
||||
/// The outcome of evaluating the policy against the current conditions.
|
||||
class EnforcementDecision {
|
||||
const EnforcementDecision({
|
||||
required this.reason,
|
||||
required this.packagesToHide,
|
||||
required this.packagesToShow,
|
||||
});
|
||||
|
||||
/// Decides which packages should be hidden right now.
|
||||
///
|
||||
/// Fail-closed on unknown location, matching `focus_daemon.sh`, which logs
|
||||
/// "Location unavailable - defaulting to focus mode". Losing GPS must not be
|
||||
/// a way to switch enforcement off.
|
||||
///
|
||||
/// Note the asymmetry that makes this safe to run unattended: every package
|
||||
/// the policy allows appears in [packagesToShow] on *every* evaluation, not
|
||||
/// only on transitions. A missed run therefore leaves apps hidden that
|
||||
/// should be visible only until the next run, and the next run repairs it
|
||||
/// without needing to know what the previous one did.
|
||||
factory EnforcementDecision.evaluate(
|
||||
FocusPolicy policy,
|
||||
EnforcementInputs inputs,
|
||||
) {
|
||||
final hasFix = inputs.latitude != null && inputs.longitude != null;
|
||||
final atHome = hasFix &&
|
||||
_isInside(
|
||||
policy,
|
||||
inputs.latitude!,
|
||||
inputs.longitude!,
|
||||
currentlyEnforcing: inputs.currentlyEnforcing,
|
||||
);
|
||||
|
||||
if (hasFix && !atHome) {
|
||||
return EnforcementDecision(
|
||||
reason: EnforcementReason.away,
|
||||
packagesToHide: const {},
|
||||
packagesToShow: inputs.installedPackages,
|
||||
);
|
||||
}
|
||||
|
||||
final duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight);
|
||||
final reason = !hasFix
|
||||
? EnforcementReason.locationUnknown
|
||||
: duringCurfew
|
||||
? EnforcementReason.curfew
|
||||
: EnforcementReason.atHome;
|
||||
|
||||
final hide = <String>{};
|
||||
final show = <String>{};
|
||||
for (final package in inputs.installedPackages) {
|
||||
if (policy.isAllowed(package, duringCurfew: duringCurfew)) {
|
||||
show.add(package);
|
||||
} else {
|
||||
hide.add(package);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.workoutActive) {
|
||||
// A workout releases its exception apps from whatever the current
|
||||
// state would otherwise be, without disturbing anything else.
|
||||
for (final package in policy.workoutExemptPackages) {
|
||||
if (hide.remove(package)) show.add(package);
|
||||
}
|
||||
return EnforcementDecision(
|
||||
reason: EnforcementReason.workout,
|
||||
packagesToHide: hide,
|
||||
packagesToShow: show,
|
||||
);
|
||||
}
|
||||
|
||||
return EnforcementDecision(
|
||||
reason: reason,
|
||||
packagesToHide: hide,
|
||||
packagesToShow: show,
|
||||
);
|
||||
}
|
||||
|
||||
/// Why this decision was reached.
|
||||
final EnforcementReason reason;
|
||||
|
||||
/// Packages that must be hidden.
|
||||
final Set<String> packagesToHide;
|
||||
|
||||
/// Packages that must be visible.
|
||||
///
|
||||
/// Always the complete allowed set, never a delta. Unhiding is the failure
|
||||
/// mode that matters: a system that fails to hide is a mild disappointment,
|
||||
/// one that fails to unhide strands the user without a dialer or a bank.
|
||||
final Set<String> packagesToShow;
|
||||
|
||||
/// Whether anything is currently restricted.
|
||||
bool get isEnforcing => packagesToHide.isNotEmpty;
|
||||
|
||||
static bool _isInside(
|
||||
FocusPolicy policy,
|
||||
double latitude,
|
||||
double longitude, {
|
||||
required bool currentlyEnforcing,
|
||||
}) {
|
||||
final home = policy.home;
|
||||
if (!home.hasCoordinates) {
|
||||
// No coordinates configured: the geofence cannot be evaluated, so it
|
||||
// must not silently pass. Treated as "at home" by the caller via the
|
||||
// hasFix path above; here we simply refuse to claim the user is away.
|
||||
return true;
|
||||
}
|
||||
final metres = _haversineMetres(
|
||||
home.latitude!,
|
||||
home.longitude!,
|
||||
latitude,
|
||||
longitude,
|
||||
);
|
||||
final threshold = home.radiusM + (currentlyEnforcing ? home.hysteresisM : 0);
|
||||
return metres <= threshold;
|
||||
}
|
||||
|
||||
static double _haversineMetres(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _radians(lat2 - lat1);
|
||||
final dLon = _radians(lon2 - lon1);
|
||||
final a = math.pow(math.sin(dLat / 2), 2) +
|
||||
math.cos(_radians(lat1)) *
|
||||
math.cos(_radians(lat2)) *
|
||||
math.pow(math.sin(dLon / 2), 2);
|
||||
return 2 * earthRadiusM * math.asin(math.sqrt(a.toDouble()));
|
||||
}
|
||||
|
||||
static double _radians(double degrees) => degrees * math.pi / 180.0;
|
||||
}
|
||||
219
focus_owner/lib/enforcement_record.dart
Normal file
219
focus_owner/lib/enforcement_record.dart
Normal file
@ -0,0 +1,219 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Why a package is hidden, mirroring Kotlin's `HideReason`.
|
||||
enum HideReason {
|
||||
alwaysBlocked,
|
||||
notInAllowlist,
|
||||
notInNightAllowlist,
|
||||
unknown;
|
||||
|
||||
static HideReason parse(String? raw) => switch (raw) {
|
||||
'ALWAYS_BLOCKED' => HideReason.alwaysBlocked,
|
||||
'NOT_IN_ALLOWLIST' => HideReason.notInAllowlist,
|
||||
'NOT_IN_NIGHT_ALLOWLIST' => HideReason.notInNightAllowlist,
|
||||
_ => HideReason.unknown,
|
||||
};
|
||||
|
||||
/// Plain-English explanation shown next to a hidden app.
|
||||
String get explanation => switch (this) {
|
||||
HideReason.alwaysBlocked => 'always blocked, everywhere',
|
||||
HideReason.notInAllowlist => 'not on the day allowlist',
|
||||
HideReason.notInNightAllowlist => 'not on the curfew allowlist',
|
||||
HideReason.unknown => 'reason not recorded',
|
||||
};
|
||||
}
|
||||
|
||||
/// One hidden package and why.
|
||||
class HiddenPackage {
|
||||
const HiddenPackage(this.package, this.reason);
|
||||
|
||||
final String package;
|
||||
final HideReason reason;
|
||||
}
|
||||
|
||||
/// One enforcement pass, as written by the Kotlin runner.
|
||||
///
|
||||
/// Deliberately a *parser*, not a second decision layer. The Kotlin side is
|
||||
/// what actually enforces; re-deriving the outcome in Dart is how the two
|
||||
/// drifted before, and a status screen that computes its own answer can
|
||||
/// confidently report "nothing blocked" while YouTube is in fact hidden.
|
||||
/// Everything here comes from the record.
|
||||
///
|
||||
/// Parsing is tolerant: a field this build does not recognise, or one written
|
||||
/// by an older build, renders as unknown rather than throwing. A debug screen
|
||||
/// that crashes on the malformed record is useless precisely when it matters.
|
||||
class EnforcementRecord {
|
||||
const EnforcementRecord({
|
||||
required this.timestamp,
|
||||
required this.reason,
|
||||
required this.distanceM,
|
||||
required this.thresholdM,
|
||||
required this.insideFence,
|
||||
required this.homeConfigured,
|
||||
required this.fixAgeMs,
|
||||
required this.fixProvider,
|
||||
required this.fixAccuracyM,
|
||||
required this.fixOutcome,
|
||||
required this.curfewActive,
|
||||
required this.curfewWindow,
|
||||
required this.hideCount,
|
||||
required this.showCount,
|
||||
required this.hidNow,
|
||||
required this.restoredNow,
|
||||
required this.hidden,
|
||||
required this.failure,
|
||||
});
|
||||
|
||||
/// Parses one JSON-lines record.
|
||||
factory EnforcementRecord.fromJson(Map<String, Object?> json) {
|
||||
final fix = json['fix'];
|
||||
final fixMap = fix is Map<String, Object?> ? fix : const <String, Object?>{};
|
||||
final counts = json['counts'];
|
||||
final countMap =
|
||||
counts is Map<String, Object?> ? counts : const <String, Object?>{};
|
||||
return EnforcementRecord(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(json['ts'] as num?)?.toInt() ?? 0,
|
||||
),
|
||||
reason: json['reason'] as String? ?? 'UNKNOWN',
|
||||
distanceM: (json['distance_m'] as num?)?.toDouble(),
|
||||
thresholdM: (json['threshold_m'] as num?)?.toDouble(),
|
||||
insideFence: json['inside_fence'] as bool?,
|
||||
homeConfigured: json['home_configured'] as bool?,
|
||||
fixAgeMs: (fixMap['age_ms'] as num?)?.toInt(),
|
||||
fixProvider: fixMap['provider'] as String?,
|
||||
fixAccuracyM: (fixMap['accuracy_m'] as num?)?.toDouble(),
|
||||
fixOutcome: fixMap['outcome'] as String?,
|
||||
curfewActive: json['curfew_active'] as bool?,
|
||||
curfewWindow: json['curfew_window'] as String?,
|
||||
hideCount: (countMap['to_hide'] as num?)?.toInt() ?? 0,
|
||||
showCount: (countMap['to_show'] as num?)?.toInt() ?? 0,
|
||||
hidNow: (countMap['hid_delta'] as num?)?.toInt() ?? 0,
|
||||
restoredNow: (countMap['restored_delta'] as num?)?.toInt() ?? 0,
|
||||
hidden: _parseHidden(json['hidden']),
|
||||
failure: json['failure'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses the raw lines returned by the platform channel, newest first.
|
||||
///
|
||||
/// A line that will not parse is skipped rather than failing the batch: one
|
||||
/// truncated record must not cost the whole history.
|
||||
static List<EnforcementRecord> parseLines(List<String> lines) {
|
||||
final records = <EnforcementRecord>[];
|
||||
for (final line in lines) {
|
||||
try {
|
||||
final decoded = jsonDecode(line);
|
||||
if (decoded is Map<String, Object?>) {
|
||||
records.add(EnforcementRecord.fromJson(decoded));
|
||||
}
|
||||
// Deliberately broad. Catching only FormatException let a
|
||||
// syntactically valid line with a wrong-typed field (say
|
||||
// `"reason": 42` from a future schema, or a half-overwritten record)
|
||||
// throw TypeError instead, and that escaped all the way out of
|
||||
// _refresh(), leaving the status page stuck busy -- which disables
|
||||
// the release-device-owner button, whose entire purpose is to be
|
||||
// reachable when everything else has gone wrong. One unreadable
|
||||
// record must never cost the escape hatch.
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
static List<HiddenPackage> _parseHidden(Object? raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(
|
||||
(e) => HiddenPackage(
|
||||
e['pkg'] as String? ?? '?',
|
||||
HideReason.parse(e['why'] as String?),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final DateTime timestamp;
|
||||
final String reason;
|
||||
final double? distanceM;
|
||||
final double? thresholdM;
|
||||
final bool? insideFence;
|
||||
final bool? homeConfigured;
|
||||
final int? fixAgeMs;
|
||||
final String? fixProvider;
|
||||
final double? fixAccuracyM;
|
||||
final String? fixOutcome;
|
||||
final bool? curfewActive;
|
||||
final String? curfewWindow;
|
||||
final int hideCount;
|
||||
final int showCount;
|
||||
final int hidNow;
|
||||
final int restoredNow;
|
||||
final List<HiddenPackage> hidden;
|
||||
final String? failure;
|
||||
|
||||
/// Whether this pass could not place the phone.
|
||||
bool get locationUnknown => reason == 'LOCATION_UNKNOWN';
|
||||
|
||||
/// Whether home was never provisioned, which enforces everywhere.
|
||||
///
|
||||
/// Kept distinct from every other state: with no coordinates the fence
|
||||
/// reports "inside" and the reason reads AT_HOME, so without this the screen
|
||||
/// would claim the phone is at home wherever it actually is.
|
||||
bool get homeMissing => homeConfigured == false;
|
||||
|
||||
/// A one-line explanation of [reason], in the user's terms.
|
||||
String get explanation {
|
||||
if (failure != null) return 'No decision was made: $failure';
|
||||
if (homeMissing) {
|
||||
return 'No home location is set, so everything is enforced everywhere. '
|
||||
'Use "Set home to current location" while you are at home.';
|
||||
}
|
||||
return switch (reason) {
|
||||
'AWAY' => 'Away from home. Everything is available except the '
|
||||
'always-blocked apps, which stay hidden wherever you are.',
|
||||
'AT_HOME' => 'At home. Only allowlisted apps are available.',
|
||||
'CURFEW' => 'Night curfew. The shorter night allowlist applies.',
|
||||
'WORKOUT' => 'Workout in progress, so the workout exceptions apply.',
|
||||
'LOCATION_UNKNOWN' =>
|
||||
'No usable location fix, so the phone is blocking as if you were at '
|
||||
'home. Losing GPS must not become a way to switch enforcement off.',
|
||||
_ => 'Unrecognised state: $reason',
|
||||
};
|
||||
}
|
||||
|
||||
/// Distance rendered for humans, or why there is none.
|
||||
String get distanceLabel {
|
||||
if (homeMissing) return 'no home set';
|
||||
final metres = distanceM;
|
||||
if (metres == null) return 'unknown - no location fix';
|
||||
final threshold = thresholdM;
|
||||
final distance =
|
||||
metres >= 1000 ? '${(metres / 1000).toStringAsFixed(1)} km' : '${metres.round()} m';
|
||||
if (threshold == null) return distance;
|
||||
return '$distance (fence ${threshold.round()} m)';
|
||||
}
|
||||
|
||||
/// Fix age, provider and accuracy on one line.
|
||||
String get fixLabel {
|
||||
final age = fixAgeMs;
|
||||
if (age == null) return fixOutcome?.toLowerCase() ?? 'no fix';
|
||||
final seconds = age ~/ 1000;
|
||||
final agePart = seconds >= 60 ? '${seconds ~/ 60} min old' : '$seconds s old';
|
||||
final parts = <String>[
|
||||
agePart,
|
||||
?fixProvider,
|
||||
if (fixAccuracyM != null) '±${fixAccuracyM!.round()} m',
|
||||
if (fixOutcome != null) fixOutcome!.toLowerCase(),
|
||||
];
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// Whether the fix was too vague to trust against a 150 m fence.
|
||||
///
|
||||
/// Surfaced because it is the tell that FINE location is not actually in
|
||||
/// effect: coarse location is fuzzed to a ~1-2 km grid.
|
||||
bool get fixLooksFuzzed => (fixAccuracyM ?? 0) > 500;
|
||||
}
|
||||
268
focus_owner/lib/enforcement_status_card.dart
Normal file
268
focus_owner/lib/enforcement_status_card.dart
Normal file
@ -0,0 +1,268 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:focus_owner/enforcement_record.dart';
|
||||
import 'package:focus_owner/theme.dart';
|
||||
|
||||
/// Renders the most recent enforcement pass, and why it decided what it did.
|
||||
///
|
||||
/// Exists because the three states were indistinguishable from the phone:
|
||||
/// LOCATION_UNKNOWN applies the identical sweep to AT_HOME, so "no GPS" and
|
||||
/// "at home" looked the same, and nothing on screen showed the distance the
|
||||
/// geofence had actually measured. Every field here answers a question that
|
||||
/// previously required reading logcat, which does not survive long enough.
|
||||
class EnforcementStatusCard extends StatelessWidget {
|
||||
const EnforcementStatusCard({
|
||||
required this.record,
|
||||
required this.onOpenLog,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The latest pass, or null when none has been recorded yet.
|
||||
final EnforcementRecord? record;
|
||||
final VoidCallback onOpenLog;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latest = record;
|
||||
if (latest == null) {
|
||||
return const _Card(
|
||||
child: Text(
|
||||
'No enforcement pass recorded yet.\n'
|
||||
'Tap "Run enforcement now" to make one.',
|
||||
style: TextStyle(color: kMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_headline(latest),
|
||||
style: TextStyle(
|
||||
color: _reasonColour(latest),
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_ago(latest.timestamp),
|
||||
style: const TextStyle(color: kMuted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kGap / 2),
|
||||
Text(
|
||||
latest.explanation,
|
||||
style: const TextStyle(color: kText, height: 1.35),
|
||||
),
|
||||
const SizedBox(height: kGap),
|
||||
_Field(label: 'Distance from home', value: latest.distanceLabel),
|
||||
_Field(
|
||||
label: 'Location fix',
|
||||
value: latest.fixLabel,
|
||||
danger: latest.fixLooksFuzzed,
|
||||
),
|
||||
if (latest.fixLooksFuzzed)
|
||||
const _Note(
|
||||
text: 'That accuracy is far wider than the fence, so the '
|
||||
'geofence cannot be trusted. Precise location is probably '
|
||||
'not in effect.',
|
||||
),
|
||||
_Field(
|
||||
label: 'Night curfew',
|
||||
value: switch (latest.curfewActive) {
|
||||
true => 'active${_window(latest)}',
|
||||
false => 'inactive${_window(latest)}',
|
||||
null => 'unknown',
|
||||
},
|
||||
),
|
||||
_Field(
|
||||
label: 'Apps',
|
||||
value: '${latest.hideCount} hidden, ${latest.showCount} available',
|
||||
),
|
||||
if (latest.hidNow > 0 || latest.restoredNow > 0)
|
||||
_Field(
|
||||
label: 'Changed this pass',
|
||||
value: 'hid ${latest.hidNow}, restored ${latest.restoredNow}',
|
||||
),
|
||||
if (_isStale(latest.timestamp))
|
||||
const _Note(
|
||||
text: 'The last pass was over 30 minutes ago. Enforcement runs '
|
||||
'every 15 minutes, so the schedule may have stalled.',
|
||||
danger: true,
|
||||
),
|
||||
if (latest.hidden.isNotEmpty) ...[
|
||||
const SizedBox(height: kGap / 2),
|
||||
_HiddenList(hidden: latest.hidden),
|
||||
],
|
||||
const SizedBox(height: kGap / 2),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: onOpenLog,
|
||||
child: const Text('Debug log'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _headline(EnforcementRecord r) {
|
||||
if (r.failure != null) return 'NO DECISION';
|
||||
if (r.homeMissing) return 'NO HOME SET';
|
||||
return r.reason.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
/// Colour-coded so the state that needs attention stands out.
|
||||
Color _reasonColour(EnforcementRecord r) {
|
||||
if (r.failure != null || r.homeMissing) return kDanger;
|
||||
return switch (r.reason) {
|
||||
'LOCATION_UNKNOWN' => kDanger,
|
||||
'AWAY' => kMuted,
|
||||
'CURFEW' => kWarn,
|
||||
_ => kAccent,
|
||||
};
|
||||
}
|
||||
|
||||
String _window(EnforcementRecord r) =>
|
||||
r.curfewWindow == null ? '' : ' (${r.curfewWindow})';
|
||||
|
||||
static bool _isStale(DateTime at) =>
|
||||
DateTime.now().difference(at) > const Duration(minutes: 30);
|
||||
|
||||
static String _ago(DateTime at) {
|
||||
final delta = DateTime.now().difference(at);
|
||||
if (delta.inMinutes < 1) return 'just now';
|
||||
if (delta.inMinutes < 60) return '${delta.inMinutes} min ago';
|
||||
if (delta.inHours < 24) return '${delta.inHours} h ago';
|
||||
return '${delta.inDays} d ago';
|
||||
}
|
||||
}
|
||||
|
||||
/// The hidden apps, each with the rule that hid it.
|
||||
class _HiddenList extends StatelessWidget {
|
||||
const _HiddenList({required this.hidden});
|
||||
|
||||
final List<HiddenPackage> hidden;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
'Why these ${hidden.length} apps are hidden',
|
||||
style: const TextStyle(color: kAccent, fontSize: 14),
|
||||
),
|
||||
children: [
|
||||
for (final entry in hidden)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.package,
|
||||
style: const TextStyle(color: kText, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kGap / 2),
|
||||
Text(
|
||||
entry.reason.explanation,
|
||||
style: TextStyle(
|
||||
color: entry.reason == HideReason.alwaysBlocked
|
||||
? kDanger
|
||||
: kMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
// A Material rather than a plain decorated box: the hidden-apps
|
||||
// ExpansionTile paints its ink on the nearest Material ancestor, and a
|
||||
// DecoratedBox in between swallows the ripple.
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: kSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(kGap),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({required this.label, required this.value, this.danger = false});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: kMuted, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: danger ? kDanger : kText,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Note extends StatelessWidget {
|
||||
const _Note({required this.text, this.danger = false});
|
||||
|
||||
final String text;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 6),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: danger ? kDanger : kWarn,
|
||||
fontSize: 12,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,10 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:focus_owner/debug_log_page.dart';
|
||||
import 'package:focus_owner/device_policy.dart';
|
||||
import 'package:focus_owner/enforcement_record.dart';
|
||||
import 'package:focus_owner/enforcement_status_card.dart';
|
||||
import 'package:focus_owner/policy.dart';
|
||||
|
||||
void main() => runApp(const FocusOwnerApp());
|
||||
@ -19,6 +22,13 @@ const Color _kDanger = Color(0xFFD9776B);
|
||||
|
||||
const double _kGap = 16;
|
||||
|
||||
/// How often to re-read the log while waiting for a pass to land.
|
||||
const Duration _kRefreshInterval = Duration(seconds: 2);
|
||||
|
||||
/// Gives up after this many polls, ~30 s — a little longer than the pass's own
|
||||
/// location timeout, so a slow fix is waited out rather than reported stale.
|
||||
const int _kRefreshAttempts = 15;
|
||||
|
||||
class FocusOwnerApp extends StatelessWidget {
|
||||
const FocusOwnerApp({super.key});
|
||||
|
||||
@ -66,6 +76,7 @@ class _StatusPageState extends State<StatusPage> {
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
bool _hasHome = false;
|
||||
List<EnforcementRecord> _log = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -75,9 +86,33 @@ class _StatusPageState extends State<StatusPage> {
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _busy = true);
|
||||
await Future.wait([_loadDeviceStatus(), _loadPolicy()]);
|
||||
try {
|
||||
await Future.wait([_loadDeviceStatus(), _loadPolicy(), _loadLog()]);
|
||||
} finally {
|
||||
// In a finally because _busy gates the release-device-owner button. If
|
||||
// any loader throws -- a malformed log record used to be enough -- the
|
||||
// flag would stay true for the life of the process, and since _refresh
|
||||
// runs from initState that means the escape hatch is dead on every
|
||||
// launch. Diagnostics failing must never disable the way out.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the durable record of past passes.
|
||||
///
|
||||
/// Failing to read it must not blank the rest of the screen: the log is
|
||||
/// diagnostics, while the provisioning state above it is what the user needs
|
||||
/// to reach the escape hatch.
|
||||
Future<void> _loadLog() async {
|
||||
try {
|
||||
final lines = await widget.policy.readEnforcementLog();
|
||||
if (!mounted) return;
|
||||
setState(() => _log = EnforcementRecord.parseLines(lines));
|
||||
} on PlatformException {
|
||||
if (!mounted) return;
|
||||
setState(() => _log = const []);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDeviceStatus() async {
|
||||
try {
|
||||
@ -117,19 +152,45 @@ class _StatusPageState extends State<StatusPage> {
|
||||
Future<void> _runNow() async {
|
||||
setState(() => _busy = true);
|
||||
final started = await widget.policy.runEnforcementNow();
|
||||
if (started) await _awaitNewRecord();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
started
|
||||
? 'Enforcement run started; schedule armed'
|
||||
? 'Enforcement run finished'
|
||||
: 'Could not start enforcement',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Waits for the pass to append a record, then shows it.
|
||||
///
|
||||
/// `runEnforcementNow` only *starts* the service, and a pass now waits up to
|
||||
/// [EnforcementRunner.ACQUIRE_TIMEOUT_MS] for a location fix, so re-reading
|
||||
/// immediately returns the record from the PREVIOUS pass. That is not a
|
||||
/// cosmetic lag: after re-anchoring home the screen kept showing the old
|
||||
/// distance, which reads as "the button did nothing" at exactly the moment
|
||||
/// the user is checking whether it worked.
|
||||
///
|
||||
/// Polls rather than waits for a callback because the pass runs in a
|
||||
/// separate service process with no channel back to the UI.
|
||||
Future<void> _awaitNewRecord() async {
|
||||
final before = _log.isEmpty ? null : _log.first.timestamp;
|
||||
for (var attempt = 0; attempt < _kRefreshAttempts; attempt++) {
|
||||
await Future<void>.delayed(_kRefreshInterval);
|
||||
if (!mounted) return;
|
||||
await _loadLog();
|
||||
if (!mounted) return;
|
||||
final latest = _log.isEmpty ? null : _log.first.timestamp;
|
||||
if (latest != null && latest != before) break;
|
||||
}
|
||||
// Provisioning rows (home set / hidden counts) move with the pass too.
|
||||
await _loadDeviceStatus();
|
||||
}
|
||||
|
||||
Future<void> _setHome() async {
|
||||
setState(() => _busy = true);
|
||||
final failure = await widget.policy.setHomeToCurrentLocation();
|
||||
@ -143,6 +204,19 @@ class _StatusPageState extends State<StatusPage> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (failure != null) {
|
||||
await _refresh();
|
||||
return;
|
||||
}
|
||||
// Re-evaluate rather than only re-reading. The stored record still
|
||||
// describes the OLD home, so refreshing alone leaves the previous
|
||||
// distance on screen -- measured: after re-anchoring home the card still
|
||||
// read "AWAY, 766 m" until a pass ran, which looks like the button
|
||||
// failed. A new home is exactly when the geofence answer changes.
|
||||
setState(() => _busy = true);
|
||||
if (await widget.policy.runEnforcementNow()) await _awaitNewRecord();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
@ -188,9 +262,26 @@ class _StatusPageState extends State<StatusPage> {
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: _kSurface,
|
||||
title: const Text('Release device owner?'),
|
||||
content: const Text(
|
||||
'The app will give up device ownership. Enforcement stops and '
|
||||
'ownership can only be restored by a factory reset.',
|
||||
// Deliberately concrete, and honest in both directions. "Restored only
|
||||
// by a factory reset" is true but abstract, and this dialog is read in
|
||||
// the moment the block is most inconvenient -- the price has to be
|
||||
// legible right then, not inferred. While no account exists the price
|
||||
// really is low, and overstating it there would teach the user to
|
||||
// ignore the warning that matters later.
|
||||
content: Text(
|
||||
_status?.hasAccounts ?? true
|
||||
? 'Enforcement stops. YouTube comes back.\n\n'
|
||||
'Accounts exist on this device, so device owner can NEVER '
|
||||
'be set again without a factory reset. That means '
|
||||
're-pairing mBank, Revolut and inFakt over SMS, '
|
||||
're-activating mObywatel, signing in to every account '
|
||||
'again, and losing Signal history.\n\n'
|
||||
'This is the way out if something is broken. It is a bad '
|
||||
'trade for wanting to watch a video.'
|
||||
: 'Enforcement stops and YouTube comes back.\n\n'
|
||||
'No account exists yet, so device owner can be set again '
|
||||
'straight afterwards without a wipe. This is the moment to '
|
||||
'test the release path, before signing in.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@ -221,6 +312,16 @@ class _StatusPageState extends State<StatusPage> {
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
void _openLog() {
|
||||
unawaited(
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => DebugLogPage(records: _log),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = _status;
|
||||
@ -239,6 +340,8 @@ class _StatusPageState extends State<StatusPage> {
|
||||
onSetHome: _setHome,
|
||||
onLockVpn: _lockVpn,
|
||||
hasHome: _hasHome,
|
||||
log: _log,
|
||||
onOpenLog: _openLog,
|
||||
);
|
||||
} else {
|
||||
body = const Center(child: CircularProgressIndicator());
|
||||
@ -279,6 +382,8 @@ class _StatusBody extends StatelessWidget {
|
||||
required this.onSetHome,
|
||||
required this.onLockVpn,
|
||||
required this.hasHome,
|
||||
this.log = const [],
|
||||
this.onOpenLog,
|
||||
});
|
||||
|
||||
final DevicePolicyStatus status;
|
||||
@ -293,19 +398,38 @@ class _StatusBody extends StatelessWidget {
|
||||
/// Whether home coordinates exist; without them enforcement is permanent.
|
||||
final bool hasHome;
|
||||
|
||||
/// Recorded passes, newest first.
|
||||
final List<EnforcementRecord> log;
|
||||
final VoidCallback? onOpenLog;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final policy = focusPolicy;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// First, and largest: what the enforcer is doing right now and why.
|
||||
// Everything below is provisioning detail that only matters once this
|
||||
// question is answered.
|
||||
EnforcementStatusCard(
|
||||
record: log.isEmpty ? null : log.first,
|
||||
onOpenLog: onOpenLog ?? () {},
|
||||
),
|
||||
const SizedBox(height: _kGap),
|
||||
_Row(label: 'Package', value: status.packageName),
|
||||
_Row(label: 'Device owner', value: status.isDeviceOwner ? 'yes' : 'no'),
|
||||
_Row(label: 'Admin active', value: status.isAdminActive ? 'yes' : 'no'),
|
||||
_Row(label: 'Android SDK', value: '${status.sdkInt}'),
|
||||
// "none (inert build)" was a leftover from when this app really did
|
||||
// apply nothing. It has been enforcing for weeks, so the honest value
|
||||
// is whether the last recorded pass hid anything.
|
||||
_Row(
|
||||
label: 'Restrictions',
|
||||
value: status.restrictionsApplied ? 'applied' : 'none (inert build)',
|
||||
label: 'Enforcing',
|
||||
value: switch (log.isEmpty ? null : log.first.hideCount) {
|
||||
null => 'no pass recorded yet',
|
||||
0 => 'nothing hidden',
|
||||
final int n => '$n apps hidden',
|
||||
},
|
||||
),
|
||||
const SizedBox(height: _kGap),
|
||||
const Divider(color: _kMuted),
|
||||
|
||||
@ -92,6 +92,8 @@ class FocusPolicy {
|
||||
required this.workoutUnblockDomains,
|
||||
required this.curfew,
|
||||
required this.launcherPackage,
|
||||
this.allowedPrefixes = const {},
|
||||
this.nightAllowedPrefixes = const {},
|
||||
});
|
||||
|
||||
/// Parses a rendered policy document.
|
||||
@ -125,6 +127,17 @@ class FocusPolicy {
|
||||
? null
|
||||
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
|
||||
launcherPackage: json['launcher_package'] as String?,
|
||||
// Optional: absent from an asset rendered before prefixes existed, and
|
||||
// reading a missing key as empty restores exactly the old exact-match
|
||||
// behaviour rather than failing to parse the whole policy.
|
||||
allowedPrefixes: _optionalStringSet(
|
||||
json,
|
||||
'allowed_prefixes',
|
||||
),
|
||||
nightAllowedPrefixes: _optionalStringSet(
|
||||
json,
|
||||
'night_allowed_prefixes',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,20 +162,39 @@ class FocusPolicy {
|
||||
final CurfewWindow? curfew;
|
||||
final String? launcherPackage;
|
||||
|
||||
/// Whether a package must never be disabled.
|
||||
/// Prefix-matched day allowlist, for apps shipping as a package family.
|
||||
///
|
||||
/// Prefix-matched on whole labels, so `com.android.providers` covers
|
||||
/// Tachiyomi installs every source as its own apk, so an exact list goes
|
||||
/// stale the moment a new extension is installed.
|
||||
final Set<String> allowedPrefixes;
|
||||
|
||||
/// Prefixes that survive the curfew. Subset of [allowedPrefixes].
|
||||
final Set<String> nightAllowedPrefixes;
|
||||
|
||||
/// Whether [package] is covered by any entry of [prefixes].
|
||||
///
|
||||
/// Matched on whole labels, so `com.android.providers` covers
|
||||
/// `com.android.providers.telephony` but not `com.android.providersomething`.
|
||||
bool isProtected(String package) => neverDisablePrefixes.any(
|
||||
/// Shared by every prefix list so the boundary rule cannot drift.
|
||||
static bool _matchesPrefix(String package, Set<String> prefixes) =>
|
||||
prefixes.any(
|
||||
(prefix) => package == prefix || package.startsWith('$prefix.'),
|
||||
);
|
||||
|
||||
/// Whether a package must never be disabled.
|
||||
bool isProtected(String package) =>
|
||||
_matchesPrefix(package, neverDisablePrefixes);
|
||||
|
||||
/// Whether a package may run under the given conditions.
|
||||
bool isAllowed(String package, {bool duringCurfew = false}) {
|
||||
if (isProtected(package)) return true;
|
||||
return duringCurfew
|
||||
? nightAllowedPackages.contains(package)
|
||||
: allowedPackages.contains(package);
|
||||
// Hiding the launcher leaves no home screen, so it outranks the lists --
|
||||
// matching FocusPolicy.kt, which has always had this check.
|
||||
if (package == launcherPackage) return true;
|
||||
final allowed = duringCurfew ? nightAllowedPackages : allowedPackages;
|
||||
if (allowed.contains(package)) return true;
|
||||
final prefixes = duringCurfew ? nightAllowedPrefixes : allowedPrefixes;
|
||||
return _matchesPrefix(package, prefixes);
|
||||
}
|
||||
|
||||
/// Whether the curfew is in force at [minutesSinceMidnight].
|
||||
@ -204,6 +236,18 @@ Set<String> _stringSet(Object? value, String field) {
|
||||
return value.map((e) => e! as String).toSet();
|
||||
}
|
||||
|
||||
/// A list that may be absent, read as empty.
|
||||
///
|
||||
/// Used for fields added after assets were already shipped. The strict
|
||||
/// [_stringSet] would throw, and an unparsable policy is worse than a missing
|
||||
/// field: it disables the feature entirely rather than degrading to the
|
||||
/// previous behaviour. A present-but-wrong type is still an error, since that
|
||||
/// is a real mistake. Mirrors `optionalStringSet` in `FocusPolicy.kt`.
|
||||
Set<String> _optionalStringSet(Map<String, Object?> json, String field) {
|
||||
if (!json.containsKey(field)) return const {};
|
||||
return _stringSet(json[field], field);
|
||||
}
|
||||
|
||||
double _requireNum(Object? value, String field) {
|
||||
if (value is! num) {
|
||||
throw PolicyFormatException('"$field" must be a number');
|
||||
|
||||
18
focus_owner/lib/theme.dart
Normal file
18
focus_owner/lib/theme.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared palette: charcoal field with a single accent, matching the other
|
||||
/// com.kuhy.* apps.
|
||||
///
|
||||
/// Lifted out of `main.dart` so the status card and the log screen use the
|
||||
/// same values rather than each redeclaring them.
|
||||
const Color kField = Color(0xFF1B1D21);
|
||||
const Color kSurface = Color(0xFF24272C);
|
||||
const Color kAccent = Color(0xFF5B9DD9);
|
||||
const Color kText = Color(0xFFE8EAED);
|
||||
const Color kMuted = Color(0xFF9AA0A6);
|
||||
const Color kDanger = Color(0xFFD9776B);
|
||||
|
||||
/// Used for states that are working as intended but restrictive.
|
||||
const Color kWarn = Color(0xFFD9B26B);
|
||||
|
||||
const double kGap = 16;
|
||||
193
focus_owner/test/enforcement_record_test.dart
Normal file
193
focus_owner/test/enforcement_record_test.dart
Normal file
@ -0,0 +1,193 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_owner/enforcement_record.dart';
|
||||
|
||||
Map<String, Object?> _doc({
|
||||
String reason = 'AT_HOME',
|
||||
Object? distanceM = 42.0,
|
||||
Object? fixAgeMs = 45000,
|
||||
Object? fixAccuracyM = 12.0,
|
||||
Object? curfewActive = false,
|
||||
Object? homeConfigured = true,
|
||||
Object? failure,
|
||||
}) =>
|
||||
{
|
||||
'v': 1,
|
||||
'ts': 1786000000000,
|
||||
'reason': reason,
|
||||
'distance_m': distanceM,
|
||||
'threshold_m': 180.0,
|
||||
'inside_fence': true,
|
||||
'home_configured': homeConfigured,
|
||||
'fix': {
|
||||
'age_ms': fixAgeMs,
|
||||
'provider': 'gps',
|
||||
'accuracy_m': fixAccuracyM,
|
||||
'outcome': 'ACTIVE_OK',
|
||||
},
|
||||
'curfew_active': curfewActive,
|
||||
'curfew_window': '23:00-05:00',
|
||||
'counts': {
|
||||
'to_hide': 12,
|
||||
'to_show': 30,
|
||||
'hid_delta': 1,
|
||||
'restored_delta': 0,
|
||||
},
|
||||
'hidden': [
|
||||
{'pkg': 'com.google.android.youtube', 'why': 'ALWAYS_BLOCKED'},
|
||||
{'pkg': 'org.mozilla.fenix', 'why': 'NOT_IN_ALLOWLIST'},
|
||||
],
|
||||
'failure': failure,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('EnforcementRecord.fromJson', () {
|
||||
test('parses a full record', () {
|
||||
final record = EnforcementRecord.fromJson(_doc());
|
||||
expect(record.reason, 'AT_HOME');
|
||||
expect(record.distanceM, 42.0);
|
||||
expect(record.thresholdM, 180.0);
|
||||
expect(record.fixProvider, 'gps');
|
||||
expect(record.hideCount, 12);
|
||||
expect(record.showCount, 30);
|
||||
expect(record.hidden.length, 2);
|
||||
expect(record.hidden.first.reason, HideReason.alwaysBlocked);
|
||||
});
|
||||
|
||||
test('tolerates every optional field being absent', () {
|
||||
// An older build's record must render rather than crash the one screen
|
||||
// that exists to explain what went wrong.
|
||||
final record = EnforcementRecord.fromJson({'reason': 'AWAY'});
|
||||
expect(record.reason, 'AWAY');
|
||||
expect(record.distanceM, isNull);
|
||||
expect(record.fixAgeMs, isNull);
|
||||
expect(record.hidden, isEmpty);
|
||||
expect(record.hideCount, 0);
|
||||
});
|
||||
|
||||
test('an unknown hide reason degrades rather than throwing', () {
|
||||
final record = EnforcementRecord.fromJson(
|
||||
_doc()
|
||||
..['hidden'] = [
|
||||
{'pkg': 'com.x', 'why': 'SOMETHING_NEW'},
|
||||
],
|
||||
);
|
||||
expect(record.hidden.single.reason, HideReason.unknown);
|
||||
expect(record.hidden.single.reason.explanation, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseLines', () {
|
||||
test('skips a corrupt line instead of losing the history', () {
|
||||
final lines = [
|
||||
jsonEncode(_doc(reason: 'AWAY')),
|
||||
'{half-written',
|
||||
jsonEncode(_doc()),
|
||||
];
|
||||
final records = EnforcementRecord.parseLines(lines);
|
||||
expect(records.length, 2);
|
||||
expect(records.first.reason, 'AWAY');
|
||||
});
|
||||
|
||||
test('an empty list parses to no records', () {
|
||||
expect(EnforcementRecord.parseLines([]), isEmpty);
|
||||
});
|
||||
|
||||
test('a wrong-typed field is skipped, not thrown', () {
|
||||
// Valid JSON, wrong type. Catching only FormatException let this throw
|
||||
// TypeError all the way out of _refresh(), which left the page stuck
|
||||
// busy and DISABLED the release-device-owner button -- the one control
|
||||
// that has to work when everything else has gone wrong.
|
||||
final records = EnforcementRecord.parseLines([
|
||||
'{"v":1,"ts":123,"reason":42}',
|
||||
jsonEncode(_doc()),
|
||||
]);
|
||||
expect(records, hasLength(1));
|
||||
expect(records.single.reason, 'AT_HOME');
|
||||
});
|
||||
|
||||
test('a wrong-typed nested field is skipped too', () {
|
||||
expect(
|
||||
EnforcementRecord.parseLines(['{"ts":1,"reason":"AWAY","counts":7}']),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
EnforcementRecord.parseLines(['{"ts":"not-a-number","reason":"AWAY"}']),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('explanations', () {
|
||||
test('LOCATION_UNKNOWN says it is blocking as if at home', () {
|
||||
// The whole point: this state used to be indistinguishable from AT_HOME.
|
||||
final record = EnforcementRecord.fromJson(
|
||||
_doc(reason: 'LOCATION_UNKNOWN', distanceM: null, fixAgeMs: null),
|
||||
);
|
||||
expect(record.locationUnknown, isTrue);
|
||||
expect(record.explanation, contains('as if you were at home'));
|
||||
expect(record.distanceLabel, 'unknown - no location fix');
|
||||
});
|
||||
|
||||
test('a missing home outranks the reason', () {
|
||||
// With no coordinates the fence reports "inside" and the reason reads
|
||||
// AT_HOME, which would otherwise claim the phone is home anywhere.
|
||||
final record = EnforcementRecord.fromJson(_doc(homeConfigured: false));
|
||||
expect(record.homeMissing, isTrue);
|
||||
expect(record.explanation, contains('No home location is set'));
|
||||
expect(record.distanceLabel, 'no home set');
|
||||
});
|
||||
|
||||
test('a failed pass explains itself', () {
|
||||
final record = EnforcementRecord.fromJson(
|
||||
_doc(failure: 'policy unreadable - no decision made'),
|
||||
);
|
||||
expect(record.explanation, contains('policy unreadable'));
|
||||
});
|
||||
|
||||
test('AWAY notes that always-blocked apps stay hidden', () {
|
||||
final record = EnforcementRecord.fromJson(_doc(reason: 'AWAY'));
|
||||
expect(record.explanation, contains('always-blocked'));
|
||||
});
|
||||
});
|
||||
|
||||
group('formatting', () {
|
||||
test('distances over a kilometre render as km', () {
|
||||
final record = EnforcementRecord.fromJson(_doc(distanceM: 10400.0));
|
||||
expect(record.distanceLabel, '10.4 km (fence 180 m)');
|
||||
});
|
||||
|
||||
test('short distances render as metres', () {
|
||||
expect(
|
||||
EnforcementRecord.fromJson(_doc(distanceM: 142.0)).distanceLabel,
|
||||
'142 m (fence 180 m)',
|
||||
);
|
||||
});
|
||||
|
||||
test('the fix line carries age, provider, accuracy and outcome', () {
|
||||
final record = EnforcementRecord.fromJson(_doc());
|
||||
expect(record.fixLabel, '45 s old · gps · ±12 m · active_ok');
|
||||
});
|
||||
|
||||
test('an older fix renders in minutes', () {
|
||||
final record = EnforcementRecord.fromJson(_doc(fixAgeMs: 1500000));
|
||||
expect(record.fixLabel, startsWith('25 min old'));
|
||||
});
|
||||
|
||||
test('no fix falls back to the outcome', () {
|
||||
final record = EnforcementRecord.fromJson(_doc(fixAgeMs: null));
|
||||
expect(record.fixLabel, 'active_ok');
|
||||
});
|
||||
|
||||
test('a very vague fix is flagged as probably not precise', () {
|
||||
// Coarse location is fuzzed to a ~1-2 km grid, which is the tell that
|
||||
// FINE is not actually in effect against a 150 m fence.
|
||||
expect(
|
||||
EnforcementRecord.fromJson(_doc(fixAccuracyM: 1400.0)).fixLooksFuzzed,
|
||||
isTrue,
|
||||
);
|
||||
expect(EnforcementRecord.fromJson(_doc()).fixLooksFuzzed, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
211
focus_owner/test/enforcement_status_card_test.dart
Normal file
211
focus_owner/test/enforcement_status_card_test.dart
Normal file
@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_owner/debug_log_page.dart';
|
||||
import 'package:focus_owner/enforcement_record.dart';
|
||||
import 'package:focus_owner/enforcement_status_card.dart';
|
||||
|
||||
EnforcementRecord _record({
|
||||
String reason = 'AT_HOME',
|
||||
Object? distanceM = 42.0,
|
||||
Object? fixAccuracyM = 12.0,
|
||||
Object? curfewActive = false,
|
||||
Object? homeConfigured = true,
|
||||
Object? failure,
|
||||
int hidDelta = 0,
|
||||
int tsOffsetMinutes = 0,
|
||||
List<Object?> hidden = const [
|
||||
{'pkg': 'com.google.android.youtube', 'why': 'ALWAYS_BLOCKED'},
|
||||
],
|
||||
}) =>
|
||||
EnforcementRecord.fromJson({
|
||||
'ts': DateTime.now()
|
||||
.subtract(Duration(minutes: tsOffsetMinutes))
|
||||
.millisecondsSinceEpoch,
|
||||
'reason': reason,
|
||||
'distance_m': distanceM,
|
||||
'threshold_m': 180.0,
|
||||
'inside_fence': true,
|
||||
'home_configured': homeConfigured,
|
||||
'fix': {
|
||||
'age_ms': 45000,
|
||||
'provider': 'gps',
|
||||
'accuracy_m': fixAccuracyM,
|
||||
'outcome': 'ACTIVE_OK',
|
||||
},
|
||||
'curfew_active': curfewActive,
|
||||
'curfew_window': '23:00-05:00',
|
||||
'counts': {
|
||||
'to_hide': 3,
|
||||
'to_show': 58,
|
||||
'hid_delta': hidDelta,
|
||||
'restored_delta': 0,
|
||||
},
|
||||
'hidden': hidden,
|
||||
'failure': failure,
|
||||
});
|
||||
|
||||
Future<void> _pump(WidgetTester tester, EnforcementRecord? record) =>
|
||||
tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: EnforcementStatusCard(record: record, onOpenLog: () {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('EnforcementStatusCard', () {
|
||||
testWidgets('says so when no pass has run yet', (tester) async {
|
||||
await _pump(tester, null);
|
||||
expect(find.textContaining('No enforcement pass recorded'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows the reason, distance and curfew state', (tester) async {
|
||||
await _pump(tester, _record());
|
||||
expect(find.text('AT HOME'), findsOneWidget);
|
||||
expect(find.text('42 m (fence 180 m)'), findsOneWidget);
|
||||
expect(find.text('inactive (23:00-05:00)'), findsOneWidget);
|
||||
expect(find.text('3 hidden, 58 available'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('LOCATION_UNKNOWN is distinguishable from AT_HOME',
|
||||
(tester) async {
|
||||
// The core of the bug being fixed: these two apply the identical sweep,
|
||||
// so without this the phone gives no way to tell them apart.
|
||||
await _pump(tester, _record(reason: 'LOCATION_UNKNOWN', distanceM: null));
|
||||
expect(find.text('LOCATION UNKNOWN'), findsOneWidget);
|
||||
expect(find.textContaining('as if you were at home'), findsOneWidget);
|
||||
expect(find.text('unknown - no location fix'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a missing home is called out rather than shown as AT_HOME',
|
||||
(tester) async {
|
||||
await _pump(tester, _record(homeConfigured: false));
|
||||
expect(find.text('NO HOME SET'), findsOneWidget);
|
||||
expect(find.text('no home set'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a failed pass reports the failure', (tester) async {
|
||||
await _pump(tester, _record(failure: 'policy unreadable'));
|
||||
expect(find.text('NO DECISION'), findsOneWidget);
|
||||
expect(find.textContaining('policy unreadable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an implausibly vague fix is flagged', (tester) async {
|
||||
await _pump(tester, _record(fixAccuracyM: 1400.0));
|
||||
expect(find.textContaining('far wider than the fence'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('curfew renders as active when it is', (tester) async {
|
||||
await _pump(tester, _record(reason: 'CURFEW', curfewActive: true));
|
||||
expect(find.text('active (23:00-05:00)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a stalled schedule is called out', (tester) async {
|
||||
await _pump(tester, _record(tsOffsetMinutes: 90));
|
||||
expect(find.textContaining('schedule may have stalled'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('changes made this pass are shown when non-zero',
|
||||
(tester) async {
|
||||
await _pump(tester, _record(hidDelta: 2));
|
||||
expect(find.text('hid 2, restored 0'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('each hidden app can be expanded to show why', (tester) async {
|
||||
await _pump(tester, _record());
|
||||
await tester.tap(find.textContaining('Why these 1 apps are hidden'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('com.google.android.youtube'), findsOneWidget);
|
||||
expect(find.text('always blocked, everywhere'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no hidden apps means no expander', (tester) async {
|
||||
await _pump(tester, _record(hidden: const []));
|
||||
expect(find.textContaining('Why these'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('never renders anything that looks like a coordinate',
|
||||
(tester) async {
|
||||
// The record carries no latitude/longitude by design; this pins that the
|
||||
// screen cannot start showing one.
|
||||
await _pump(tester, _record());
|
||||
final texts = tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.map((t) => t.data ?? '')
|
||||
.join(' ');
|
||||
expect(RegExp(r'\d{1,3}\.\d{4,}').hasMatch(texts), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the log button is reachable', (tester) async {
|
||||
var opened = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: EnforcementStatusCard(
|
||||
record: _record(),
|
||||
onOpenLog: () => opened = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Debug log'));
|
||||
expect(opened, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('DebugLogPage', () {
|
||||
testWidgets('explains itself when empty', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: DebugLogPage(records: [])),
|
||||
);
|
||||
expect(find.textContaining('No passes recorded yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists passes newest first and expands one', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DebugLogPage(
|
||||
records: [
|
||||
_record(reason: 'AWAY', distanceM: 10400.0),
|
||||
_record(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('AWAY'), findsOneWidget);
|
||||
expect(find.text('10.4 km (fence 180 m)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('AWAY'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Fix'), findsOneWidget);
|
||||
expect(find.text('Copy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a failed pass is marked in the list', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DebugLogPage(records: [_record(failure: 'policy unreadable')]),
|
||||
),
|
||||
);
|
||||
expect(find.text('NO DECISION'), findsOneWidget);
|
||||
await tester.tap(find.text('NO DECISION'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Failure'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('copying a record does not throw', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: DebugLogPage(records: [_record()])),
|
||||
);
|
||||
await tester.tap(find.text('AT HOME'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Copy'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,276 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_owner/enforcement.dart';
|
||||
import 'package:focus_owner/policy.dart';
|
||||
|
||||
/// Warsaw, used only as a fixed reference point for the geofence maths.
|
||||
const double _homeLat = 52.2297;
|
||||
const double _homeLon = 21.0122;
|
||||
const double _metresPerDegreeLat = 111320;
|
||||
|
||||
FocusPolicy _policy({bool withCoordinates = true, Object? curfew}) =>
|
||||
FocusPolicy.fromJson({
|
||||
'schema_version': kSupportedSchemaVersion,
|
||||
'home': {
|
||||
'latitude': withCoordinates ? _homeLat : null,
|
||||
'longitude': withCoordinates ? _homeLon : null,
|
||||
'radius_m': 150.0,
|
||||
'hysteresis_m': 30.0,
|
||||
},
|
||||
'curfew': curfew ?? const {'start': '23:00', 'end': '05:00'},
|
||||
'launcher_package': 'com.launcher',
|
||||
'allowed_packages': [
|
||||
'com.launcher',
|
||||
'pl.mbank',
|
||||
'com.discord',
|
||||
'com.google.android.youtube',
|
||||
],
|
||||
'night_allowed_packages': ['com.launcher', 'pl.mbank'],
|
||||
'never_disable_prefixes': ['com.android.settings'],
|
||||
'workout_unblock_domains': ['youtube.com', 'googlevideo.com'],
|
||||
'browser_packages': <String>[],
|
||||
});
|
||||
|
||||
const _installed = {
|
||||
'com.launcher',
|
||||
'pl.mbank',
|
||||
'com.discord',
|
||||
'com.google.android.youtube',
|
||||
'com.evil',
|
||||
'com.android.settings',
|
||||
};
|
||||
|
||||
/// A point [metres] north of home.
|
||||
double _latOffset(double metres) => _homeLat + metres / _metresPerDegreeLat;
|
||||
|
||||
void main() {
|
||||
group('away from home', () {
|
||||
test('nothing is hidden and everything is shown', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(5000),
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.away);
|
||||
expect(decision.packagesToHide, isEmpty);
|
||||
expect(decision.packagesToShow, _installed);
|
||||
expect(decision.isEnforcing, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('at home during the day', () {
|
||||
late EnforcementDecision decision;
|
||||
|
||||
setUp(() {
|
||||
decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('only non-allowlisted apps are hidden', () {
|
||||
expect(decision.reason, EnforcementReason.atHome);
|
||||
expect(decision.packagesToHide, {'com.evil'});
|
||||
});
|
||||
|
||||
test('protected system packages are never hidden', () {
|
||||
expect(decision.packagesToShow, contains('com.android.settings'));
|
||||
});
|
||||
|
||||
test('the launcher is never hidden', () {
|
||||
expect(decision.packagesToShow, contains('com.launcher'));
|
||||
});
|
||||
});
|
||||
|
||||
group('curfew', () {
|
||||
test('the night list is much shorter than the day list', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 23 * 60 + 30,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.curfew);
|
||||
expect(decision.packagesToHide, containsAll(['com.discord', 'com.evil']));
|
||||
expect(decision.packagesToShow, containsAll(['pl.mbank', 'com.launcher']));
|
||||
});
|
||||
|
||||
test('curfew wraps past midnight', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 3 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.curfew);
|
||||
});
|
||||
|
||||
test('after the curfew ends the day list applies again', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 6 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.atHome);
|
||||
expect(decision.packagesToShow, contains('com.discord'));
|
||||
});
|
||||
});
|
||||
|
||||
group('geofence hysteresis', () {
|
||||
// 160m is outside the 150m radius but inside radius+hysteresis (180m), so
|
||||
// the answer must depend on the current state or the enforcer flaps.
|
||||
EnforcementDecision at160m({required bool enforcing}) =>
|
||||
EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(160),
|
||||
longitude: _homeLon,
|
||||
currentlyEnforcing: enforcing,
|
||||
),
|
||||
);
|
||||
|
||||
test('arriving: not yet inside', () {
|
||||
expect(at160m(enforcing: false).reason, EnforcementReason.away);
|
||||
});
|
||||
|
||||
test('leaving: still inside', () {
|
||||
expect(at160m(enforcing: true).reason, EnforcementReason.atHome);
|
||||
});
|
||||
|
||||
test('well beyond the band is away in both states', () {
|
||||
for (final enforcing in [true, false]) {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(500),
|
||||
longitude: _homeLon,
|
||||
currentlyEnforcing: enforcing,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.away);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('fail-closed behaviour', () {
|
||||
test('unknown location enforces rather than releasing', () {
|
||||
// Losing GPS must not be a way to switch enforcement off. Matches
|
||||
// focus_daemon.sh, which defaults to focus mode when location fails.
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.locationUnknown);
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
});
|
||||
|
||||
test('a policy with redacted coordinates still enforces', () {
|
||||
// The committed policy.json has no coordinates. That must not silently
|
||||
// disable the geofence into "always away".
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(withCoordinates: false),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(5000),
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
});
|
||||
});
|
||||
|
||||
group('workout exception', () {
|
||||
test('releases YouTube without releasing anything else', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 23 * 60 + 30,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
workoutActive: true,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.workout);
|
||||
expect(decision.packagesToShow, contains('com.google.android.youtube'));
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
expect(decision.packagesToHide, contains('com.discord'));
|
||||
});
|
||||
|
||||
test('domains map to packages, since hiding cannot target a domain', () {
|
||||
expect(
|
||||
_policy().workoutExemptPackages,
|
||||
contains('com.google.android.youtube'),
|
||||
);
|
||||
});
|
||||
|
||||
test('no workout domains means no exempt packages', () {
|
||||
final policy = FocusPolicy.fromJson({
|
||||
'schema_version': kSupportedSchemaVersion,
|
||||
'home': {
|
||||
'latitude': null,
|
||||
'longitude': null,
|
||||
'radius_m': 150.0,
|
||||
'hysteresis_m': 30.0,
|
||||
},
|
||||
'curfew': null,
|
||||
'launcher_package': null,
|
||||
'allowed_packages': <String>[],
|
||||
'night_allowed_packages': <String>[],
|
||||
'never_disable_prefixes': <String>[],
|
||||
'workout_unblock_domains': <String>[],
|
||||
'browser_packages': <String>[],
|
||||
});
|
||||
expect(policy.workoutExemptPackages, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('unhide reliability', () {
|
||||
test('packagesToShow is the full allowed set, not a delta', () {
|
||||
// The service applies packagesToShow unconditionally on every run, so a
|
||||
// missed evaluation self-repairs. A delta would require knowing what the
|
||||
// previous run did, and a reboot loses that.
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
final union = {...decision.packagesToHide, ...decision.packagesToShow};
|
||||
expect(union, _installed);
|
||||
expect(
|
||||
decision.packagesToHide.intersection(decision.packagesToShow),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -128,6 +128,63 @@ void main() {
|
||||
expect(policy.isAllowed('com.evil'), isFalse);
|
||||
});
|
||||
|
||||
test('the launcher is allowed even when absent from the lists', () {
|
||||
// Hiding it leaves no home screen. FocusPolicy.kt has always had this
|
||||
// check; Dart had drifted without it.
|
||||
expect(policy.allowedPackages, isNot(contains(policy.launcherPackage)));
|
||||
expect(policy.isAllowed('com.qqlabs.minimalistlauncher'), isTrue);
|
||||
expect(
|
||||
policy.isAllowed('com.qqlabs.minimalistlauncher', duringCurfew: true),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('an asset without prefix keys parses as empty', () {
|
||||
// Back-compat: a policy rendered before prefixes existed must still
|
||||
// parse, degrading to exact matching rather than failing entirely.
|
||||
expect(policy.allowedPrefixes, isEmpty);
|
||||
expect(policy.nightAllowedPrefixes, isEmpty);
|
||||
});
|
||||
|
||||
test('allowed prefixes cover a family of packages', () {
|
||||
final prefixed = FocusPolicy.fromJson(
|
||||
_doc()
|
||||
..['allowed_prefixes'] = ['eu.kanade.tachiyomi']
|
||||
..['night_allowed_prefixes'] = ['eu.kanade.tachiyomi'],
|
||||
);
|
||||
expect(prefixed.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
|
||||
expect(
|
||||
prefixed.isAllowed('eu.kanade.tachiyomi.extension.all.mangadex'),
|
||||
isTrue,
|
||||
);
|
||||
// Whole labels only, so an unrelated package is not swept in.
|
||||
expect(prefixed.isAllowed('eu.kanade.tachiyomisomething'), isFalse);
|
||||
// Chosen 2026-08-14: manga stays available during the curfew.
|
||||
expect(
|
||||
prefixed.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a day-only prefix is blocked during curfew', () {
|
||||
final dayOnly = FocusPolicy.fromJson(
|
||||
_doc()..['allowed_prefixes'] = ['eu.kanade.tachiyomi'],
|
||||
);
|
||||
expect(dayOnly.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
|
||||
expect(
|
||||
dayOnly.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a prefix list of the wrong type is still an error', () {
|
||||
// Absent is fine; present-but-wrong is a real mistake.
|
||||
expect(
|
||||
() => FocusPolicy.fromJson(_doc()..['allowed_prefixes'] = 'nope'),
|
||||
throwsA(isA<PolicyFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('isCurfewActive follows the window', () {
|
||||
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
|
||||
expect(policy.isCurfewActive(12 * 60), isFalse);
|
||||
@ -154,7 +211,14 @@ void main() {
|
||||
// Guards the generator: a policy.json that this app cannot read, or that
|
||||
// leaks the home coordinates into git, is a build error.
|
||||
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
|
||||
expect(policy.allowedPackages.length, greaterThan(50));
|
||||
// Guards against an empty or truncated render, not against a specific
|
||||
// size. The threshold was 50 when the allowlist still described the rooted
|
||||
// Blackview; the 2026-08-11 rewrite to an explicit list for this phone cut
|
||||
// it to ~26, so a high bound would only pin the old policy in place.
|
||||
expect(policy.allowedPackages.length, greaterThan(10));
|
||||
// The launcher must survive enforcement or the device has no home screen.
|
||||
expect(policy.launcherPackage, isNotNull);
|
||||
expect(policy.allowedPackages, contains(policy.launcherPackage));
|
||||
expect(policy.home.hasCoordinates, isFalse,
|
||||
reason: 'committed policy.json must not carry home coordinates');
|
||||
expect(policy.curfew, isNotNull);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@ -62,8 +64,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('this build never reports restrictions as applied', () {
|
||||
// The app is provisioning-capable but inert; enforcement ships only
|
||||
// after the release path is verified on a device.
|
||||
// Not a claim that nothing is enforced -- the app is live and hiding
|
||||
// packages, and it does apply user restrictions once the VPN and DNS
|
||||
// layers are locked. The native side simply never reads this back, so
|
||||
// it is always false; the real state is the hidden-app count.
|
||||
expect(
|
||||
DevicePolicyStatus.fromMap(_statusMap()).restrictionsApplied,
|
||||
isFalse,
|
||||
@ -129,7 +133,11 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Release device owner'), findsOneWidget);
|
||||
expect(find.text('none (inert build)'), findsOneWidget);
|
||||
// The old "none (inert build)" label was a leftover from before this app
|
||||
// enforced anything, and it stayed on screen for weeks while YouTube was
|
||||
// in fact being hidden. With no pass recorded the honest answer is that
|
||||
// nothing is known yet, not that nothing is applied.
|
||||
expect(find.text('no pass recorded yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reports when no home is set, since that means everywhere',
|
||||
@ -230,5 +238,114 @@ void main() {
|
||||
);
|
||||
expect(find.text('Device owner released'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('running enforcement waits for the new record', (tester) async {
|
||||
// The pass runs in a separate service process and now waits for a
|
||||
// location fix, so re-reading immediately returns the PREVIOUS record.
|
||||
// Measured: after re-anchoring home the card still showed the old
|
||||
// distance, which reads as "the button did nothing".
|
||||
var passRan = false;
|
||||
const channel = MethodChannel('test/refresh');
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'status':
|
||||
return _statusMap(isDeviceOwner: true);
|
||||
case 'hasHomeLocation':
|
||||
return true;
|
||||
case 'runEnforcementNow':
|
||||
passRan = true;
|
||||
return true;
|
||||
case 'readEnforcementLog':
|
||||
return [_logLine(passRan ? 'AT_HOME' : 'AWAY')];
|
||||
default:
|
||||
throw PlatformException(code: 'unimplemented');
|
||||
}
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatusPage(
|
||||
policy: DevicePolicy(channel),
|
||||
loadFocusPolicy: _stubPolicy,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('AWAY'), findsOneWidget);
|
||||
|
||||
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
|
||||
await tester.tap(find.text('Run enforcement now'));
|
||||
// Long enough for at least one poll interval to elapse.
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AT HOME'), findsOneWidget);
|
||||
expect(find.text('AWAY'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a stuck pass stops polling instead of hanging',
|
||||
(tester) async {
|
||||
// If no new record ever lands the UI must still settle, not spin.
|
||||
const channel = MethodChannel('test/stuck');
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'status':
|
||||
return _statusMap(isDeviceOwner: true);
|
||||
case 'hasHomeLocation':
|
||||
return true;
|
||||
case 'runEnforcementNow':
|
||||
return true;
|
||||
case 'readEnforcementLog':
|
||||
return [_logLine('AWAY')];
|
||||
default:
|
||||
throw PlatformException(code: 'unimplemented');
|
||||
}
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatusPage(
|
||||
policy: DevicePolicy(channel),
|
||||
loadFocusPolicy: _stubPolicy,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
|
||||
await tester.tap(find.text('Run enforcement now'));
|
||||
// Past the polling budget (~30 s).
|
||||
await tester.pump(const Duration(seconds: 40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AWAY'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// One JSON-lines record, as the platform channel returns them.
|
||||
String _logLine(String reason) => jsonEncode({
|
||||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||
'reason': reason,
|
||||
'distance_m': reason == 'AWAY' ? 766.0 : 0.0,
|
||||
'threshold_m': 150.0,
|
||||
'inside_fence': reason != 'AWAY',
|
||||
'home_configured': true,
|
||||
'fix': {
|
||||
'age_ms': 5000,
|
||||
'provider': 'gps',
|
||||
'accuracy_m': 11.0,
|
||||
'outcome': 'ACTIVE_OK',
|
||||
},
|
||||
'curfew_active': false,
|
||||
'curfew_window': '23:00-05:00',
|
||||
'counts': {
|
||||
'to_hide': 3,
|
||||
'to_show': 58,
|
||||
'hid_delta': 0,
|
||||
'restored_delta': 0,
|
||||
},
|
||||
'hidden': const <Object?>[],
|
||||
'failure': null,
|
||||
});
|
||||
|
||||
@ -386,18 +386,67 @@ com.ichi2.anki
|
||||
com.metrolist.music
|
||||
eu.kanade.tachiyomi.sy
|
||||
|
||||
# --- App store (day only; deliberately absent from $NIGHT_WHITELIST) ---
|
||||
# infakt cannot be installed or updated without Play, and it is device-paired
|
||||
# to a bank, so losing the ability to update it strands a re-authentication
|
||||
# chain. This does NOT reopen YouTube: the sweep is default-deny for
|
||||
# third-party packages, so anything installed from Play is hidden on the next
|
||||
# at-home pass, and $ALWAYS_BLOCKED (YouTube, Chrome) is never restored by the
|
||||
# AWAY branch either. Reinstalling YouTube from Play is UNTESTED as a bypass:
|
||||
# the sweep would re-hide it within one pass regardless, but treat the claim
|
||||
# that Play cannot resurrect a blocked app as unverified until someone tries.
|
||||
# NOTE: never use a double-quote character anywhere inside these export
|
||||
# blocks, not even in a comment. The policy loader matches a quoted value up
|
||||
# to the next quote, so one stray quote terminates the string early and
|
||||
# silently truncates the allowlist -- which drops apps with no error at all.
|
||||
com.android.vending
|
||||
|
||||
# --- Workout tracking (always beneficial; must stay enabled to export runs) ---
|
||||
org.runnerup
|
||||
org.runnerup.free
|
||||
"
|
||||
|
||||
# ============================================================
|
||||
# ALLOWED PACKAGE PREFIXES
|
||||
# Matched as prefixes on whole labels, exactly like $SYSTEM_NEVER_DISABLE:
|
||||
# "eu.kanade.tachiyomi" covers "eu.kanade.tachiyomi.sy" and
|
||||
# "eu.kanade.tachiyomi.extension.all.mangadex", but not
|
||||
# "eu.kanade.tachiyomisomething".
|
||||
#
|
||||
# This exists because Tachiyomi installs every source as its OWN apk. Listing
|
||||
# them individually means each newly installed extension is invisible until
|
||||
# this file is edited and the policy regenerated -- a recurring chore that
|
||||
# looks exactly like a bug from the phone.
|
||||
#
|
||||
# Weaker than the exact list by construction: a prefix allows packages that do
|
||||
# not exist yet. Keep the prefixes narrow and vendor-specific for that reason.
|
||||
# ============================================================
|
||||
|
||||
export ALLOWED_PREFIXES="
|
||||
# Manga reader + its per-source extension apks.
|
||||
eu.kanade.tachiyomi
|
||||
"
|
||||
|
||||
# Prefixes that survive the curfew as well. Must be a subset of
|
||||
# $ALLOWED_PREFIXES, mirroring the NIGHT_WHITELIST/WHITELIST subset rule.
|
||||
export NIGHT_ALLOWED_PREFIXES="
|
||||
eu.kanade.tachiyomi
|
||||
"
|
||||
|
||||
# ============================================================
|
||||
# NIGHT CURFEW WHITELIST
|
||||
# These are the ONLY third-party apps that stay enabled during the curfew
|
||||
# window (see NIGHT_CURFEW_* above). Everything else in $WHITELIST — browsers,
|
||||
# social, messaging, email, media, manga, stores, transit — is disabled.
|
||||
# social, messaging, email, stores, transit — is disabled.
|
||||
# Allow-list by design: when in doubt, leave it OUT.
|
||||
#
|
||||
# EXCEPTION: $NIGHT_ALLOWED_PREFIXES is applied on top of this list, and it
|
||||
# currently carries "eu.kanade.tachiyomi" — so manga IS available during the
|
||||
# curfew, deliberately (chosen 2026-08-14). This paragraph used to say manga
|
||||
# was disabled at night; it was true until that change. Do not "restore" it
|
||||
# without also emptying $NIGHT_ALLOWED_PREFIXES, or the comment and the
|
||||
# behaviour disagree again.
|
||||
#
|
||||
# Parsed exactly like $WHITELIST (one package per line, '#' comments ignored).
|
||||
# The sysprotect prefixes ($SYSTEM_NEVER_DISABLE) and the default-handler guard
|
||||
# (dialer/SMS/home/browser/IME) still apply on TOP of this list, so the active
|
||||
|
||||
@ -147,20 +147,44 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
|
||||
{*policy.night_allowed_packages, ENFORCER_PACKAGE},
|
||||
),
|
||||
"never_disable_prefixes": sorted(policy.never_disable_prefixes),
|
||||
# Prefix-matched allowlists. Absent from an older asset, so both
|
||||
# parsers must read a missing key as the empty tuple -- which restores
|
||||
# exactly the previous exact-match-only behaviour.
|
||||
"allowed_prefixes": sorted(policy.allowed_prefixes),
|
||||
"night_allowed_prefixes": sorted(policy.night_allowed_prefixes),
|
||||
"workout_unblock_domains": sorted(policy.workout_unblock_domains),
|
||||
"browser_packages": sorted(policy.browser_packages),
|
||||
# Absent from an older asset, so the Kotlin loader must treat a
|
||||
# missing key as the empty set -- i.e. keep today's behaviour of
|
||||
# never touching a system app.
|
||||
#
|
||||
# Only the enforcer is subtracted. The allowlist is deliberately NOT,
|
||||
# because sweepable != hideable: being in this list makes a package
|
||||
# *eligible for a decision*, and `isAllowed` is what then protects it.
|
||||
# Removing an allowed system package from the sweep does not protect
|
||||
# it, it freezes it -- `sweepablePackages` drops any FLAG_SYSTEM
|
||||
# package absent from here, so it lands in neither `packagesToHide`
|
||||
# nor `packagesToShow` and keeps whatever state it was last left in.
|
||||
# Measured: com.android.vending is hidden today, so allowlisting it
|
||||
# while subtracting here would have left Play hidden permanently, with
|
||||
# no in-app way to notice. Same failure as the MATCH_UNINSTALLED_PACKAGES
|
||||
# note in EnforcementRunner, reached by a different route.
|
||||
"blockable_system_packages": sorted(
|
||||
BLOCKABLE_SYSTEM_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE},
|
||||
BLOCKABLE_SYSTEM_PACKAGES - {ENFORCER_PACKAGE},
|
||||
),
|
||||
# Same subtraction, for the same reason: an allowed package must never
|
||||
# also be declared always-blocked, or the asset would state both at
|
||||
# once. The enforcer itself can never appear here -- hiding it takes
|
||||
# the escape hatch with it.
|
||||
# An allowed package must never also be declared always-blocked, or the
|
||||
# asset would state both at once and the outcome would depend on which
|
||||
# check the runner happened to apply first. The enforcer itself can
|
||||
# never appear here -- hiding it takes the escape hatch with it.
|
||||
#
|
||||
# Filtered through `is_allowed` rather than subtracting the exact
|
||||
# allowlist, because allowance is no longer exact-only: a prefix that
|
||||
# happened to cover an always-blocked package would otherwise satisfy
|
||||
# `is_allowed` while still being emitted as always-blocked.
|
||||
"always_blocked_packages": sorted(
|
||||
ALWAYS_BLOCKED_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE},
|
||||
pkg
|
||||
for pkg in ALWAYS_BLOCKED_PACKAGES - {ENFORCER_PACKAGE}
|
||||
if not policy.is_allowed(pkg)
|
||||
),
|
||||
"vpn_lockdown": VPN_LOCKDOWN,
|
||||
"private_dns_host": PRIVATE_DNS_HOST,
|
||||
|
||||
@ -174,4 +174,10 @@ def load_policy(
|
||||
curfew=curfew,
|
||||
launcher_package=launcher,
|
||||
browser_packages=parse_package_list(values.get("BROWSER_PACKAGES", "")),
|
||||
allowed_prefixes=tuple(
|
||||
sorted(parse_package_list(values.get("ALLOWED_PREFIXES", "")))
|
||||
),
|
||||
night_allowed_prefixes=tuple(
|
||||
sorted(parse_package_list(values.get("NIGHT_ALLOWED_PREFIXES", "")))
|
||||
),
|
||||
)
|
||||
|
||||
@ -14,6 +14,7 @@ import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from datetime import time
|
||||
|
||||
|
||||
@ -119,6 +120,11 @@ class FocusPolicy:
|
||||
curfew: CurfewWindow | None = None
|
||||
launcher_package: str | None = None
|
||||
browser_packages: frozenset[str] = field(default_factory=frozenset)
|
||||
# Prefix-matched allowlists, for apps that ship as a family of packages.
|
||||
# Tachiyomi installs every source as its own apk, so an exact list goes
|
||||
# stale the moment a new extension is installed.
|
||||
allowed_prefixes: tuple[str, ...] = ()
|
||||
night_allowed_prefixes: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject policies that would lock the user out of the device."""
|
||||
@ -135,6 +141,29 @@ class FocusPolicy:
|
||||
f"unknown at day level: {sorted(orphans)}"
|
||||
)
|
||||
raise PolicyError(msg)
|
||||
# Same subset rule as the package lists: the curfew is meant to be a
|
||||
# tightening of the day policy, so a prefix allowed only at night would
|
||||
# invert that and be easy to miss in review.
|
||||
prefix_orphans = set(self.night_allowed_prefixes) - set(self.allowed_prefixes)
|
||||
if prefix_orphans:
|
||||
msg = (
|
||||
"night_allowed_prefixes must be a subset of allowed_prefixes; "
|
||||
f"unknown at day level: {sorted(prefix_orphans)}"
|
||||
)
|
||||
raise PolicyError(msg)
|
||||
|
||||
@staticmethod
|
||||
def _matches_prefix(package: str, prefixes: Sequence[str]) -> bool:
|
||||
"""Return whether ``package`` is covered by any entry of ``prefixes``.
|
||||
|
||||
Matched on whole labels, so ``com.android.providers`` covers
|
||||
``com.android.providers.telephony`` but not
|
||||
``com.android.providersomething``. Shared by every prefix list so the
|
||||
boundary rule cannot drift between them.
|
||||
"""
|
||||
return any(
|
||||
package == prefix or package.startswith(f"{prefix}.") for prefix in prefixes
|
||||
)
|
||||
|
||||
def is_protected(self, package: str) -> bool:
|
||||
"""Return whether a package must never be disabled.
|
||||
@ -144,23 +173,26 @@ class FocusPolicy:
|
||||
implementation, so that ``com.android.providers.telephony`` is covered
|
||||
by the ``com.android.providers`` entry.
|
||||
"""
|
||||
return any(
|
||||
package == prefix or package.startswith(f"{prefix}.")
|
||||
for prefix in self.never_disable_prefixes
|
||||
)
|
||||
return self._matches_prefix(package, self.never_disable_prefixes)
|
||||
|
||||
def is_allowed(self, package: str, *, during_curfew: bool = False) -> bool:
|
||||
"""Return whether a package may run under the given conditions.
|
||||
|
||||
Protected system packages are always allowed. During curfew the much
|
||||
smaller ``night_allowed_packages`` set applies instead of the day list.
|
||||
smaller ``night_allowed_packages`` set applies instead of the day list,
|
||||
and likewise for the prefix lists.
|
||||
"""
|
||||
if self.is_protected(package):
|
||||
return True
|
||||
allowed = (
|
||||
self.night_allowed_packages if during_curfew else self.allowed_packages
|
||||
if during_curfew:
|
||||
return package in self.night_allowed_packages or self._matches_prefix(
|
||||
package,
|
||||
self.night_allowed_prefixes,
|
||||
)
|
||||
return package in self.allowed_packages or self._matches_prefix(
|
||||
package,
|
||||
self.allowed_prefixes,
|
||||
)
|
||||
return package in allowed
|
||||
|
||||
def is_curfew_active(self, moment: time) -> bool:
|
||||
"""Return whether the night curfew is in force at ``moment``."""
|
||||
|
||||
@ -26,6 +26,7 @@ def _policy(
|
||||
night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
|
||||
curfew: CurfewWindow | None = _DEFAULT_CURFEW,
|
||||
never_disable_prefixes: tuple[str, ...] = ("com.android.",),
|
||||
allowed_prefixes: tuple[str, ...] = (),
|
||||
) -> FocusPolicy:
|
||||
"""Build a policy with the enforcer deliberately absent from both lists."""
|
||||
return FocusPolicy(
|
||||
@ -38,6 +39,7 @@ def _policy(
|
||||
curfew=curfew,
|
||||
launcher_package="com.launcher",
|
||||
allowed_packages=allowed_packages,
|
||||
allowed_prefixes=allowed_prefixes,
|
||||
night_allowed_packages=night_allowed_packages,
|
||||
never_disable_prefixes=never_disable_prefixes,
|
||||
workout_unblock_domains=frozenset({"youtube.com"}),
|
||||
@ -86,22 +88,29 @@ def test_blockable_system_packages_names_the_distraction_apps() -> None:
|
||||
assert "com.android.vending" in blockable
|
||||
|
||||
|
||||
def test_blockable_system_packages_never_contradicts_the_allowlist() -> None:
|
||||
"""An allowed package must not also be listed as blockable.
|
||||
def test_an_allowed_system_package_stays_sweepable() -> None:
|
||||
"""An allowed system package must stay in the sweep, or it freezes.
|
||||
|
||||
Otherwise the asset would state both at once and the outcome would depend
|
||||
on which check the runner happened to apply first.
|
||||
Sweepable is not the same as hideable. This list decides which FLAG_SYSTEM
|
||||
packages are *eligible for a decision*; ``is_allowed`` is what then
|
||||
protects them. ``sweepablePackages`` in the Kotlin runner drops any system
|
||||
package absent from here, so an allowed-but-unsweepable package appears in
|
||||
neither ``packagesToHide`` nor ``packagesToShow`` and keeps whatever state
|
||||
it was last left in.
|
||||
|
||||
Measured on device: Play was hidden, so subtracting the allowlist here
|
||||
would have stranded it hidden permanently the moment it was allowlisted.
|
||||
"""
|
||||
payload = policy_to_dict(
|
||||
_policy(
|
||||
allowed_packages=frozenset(
|
||||
{"pl.mbank", "com.launcher", "com.android.chrome"},
|
||||
{"pl.mbank", "com.launcher", "com.android.vending"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert "com.android.chrome" not in payload["blockable_system_packages"]
|
||||
assert "com.android.chrome" in payload["allowed_packages"]
|
||||
assert "com.android.vending" in payload["blockable_system_packages"]
|
||||
assert "com.android.vending" in payload["allowed_packages"]
|
||||
|
||||
|
||||
def test_always_blocked_exempts_youtube_and_chrome_from_the_geofence() -> None:
|
||||
@ -183,6 +192,31 @@ def test_always_blocked_never_contradicts_the_allowlist() -> None:
|
||||
assert "com.android.chrome" in payload["allowed_packages"]
|
||||
|
||||
|
||||
def test_a_prefix_allowance_also_wins_over_always_blocking() -> None:
|
||||
"""Allowance is no longer exact-only, so the check must not be either.
|
||||
|
||||
Subtracting just ``allowed_packages`` left a package allowed by prefix
|
||||
still emitted as always-blocked, so the asset stated both at once and the
|
||||
runner's behaviour depended on which check it applied first.
|
||||
"""
|
||||
payload = policy_to_dict(
|
||||
_policy(
|
||||
allowed_packages=frozenset({"pl.mbank", "com.launcher"}),
|
||||
allowed_prefixes=("com.android.chrome",),
|
||||
),
|
||||
)
|
||||
|
||||
assert "com.android.chrome" not in payload["always_blocked_packages"]
|
||||
|
||||
|
||||
def test_the_real_distraction_apps_stay_always_blocked() -> None:
|
||||
"""The filter must not quietly un-block what the app exists to block."""
|
||||
payload = policy_to_dict(_policy())
|
||||
|
||||
assert "com.google.android.youtube" in payload["always_blocked_packages"]
|
||||
assert "com.android.chrome" in payload["always_blocked_packages"]
|
||||
|
||||
|
||||
def test_lists_are_sorted_for_stable_diffs() -> None:
|
||||
"""Sorted output keeps a committed asset's diff meaningful."""
|
||||
payload = policy_to_dict(_policy())
|
||||
|
||||
@ -23,6 +23,8 @@ def _policy(
|
||||
night_allowed_packages: frozenset[str] | None = None,
|
||||
curfew: CurfewWindow | None = None,
|
||||
launcher_package: str | None = None,
|
||||
allowed_prefixes: tuple[str, ...] = (),
|
||||
night_allowed_prefixes: tuple[str, ...] = (),
|
||||
) -> FocusPolicy:
|
||||
"""Build a small policy, overriding individual fields per test."""
|
||||
return FocusPolicy(
|
||||
@ -40,6 +42,8 @@ def _policy(
|
||||
never_disable_prefixes=("com.android.providers", "com.android.settings"),
|
||||
curfew=curfew,
|
||||
launcher_package=launcher_package,
|
||||
allowed_prefixes=allowed_prefixes,
|
||||
night_allowed_prefixes=night_allowed_prefixes,
|
||||
)
|
||||
|
||||
|
||||
@ -201,6 +205,45 @@ class TestFocusPolicy:
|
||||
"""Anything not explicitly allowed is denied."""
|
||||
assert not _policy().is_allowed("com.evil")
|
||||
|
||||
def test_allowed_prefix_covers_a_family_of_packages(self) -> None:
|
||||
"""Tachiyomi ships every source as its own apk.
|
||||
|
||||
An exact list goes stale the moment a new extension is installed, which
|
||||
looks exactly like a bug from the phone.
|
||||
"""
|
||||
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
|
||||
assert policy.is_allowed("eu.kanade.tachiyomi")
|
||||
assert policy.is_allowed("eu.kanade.tachiyomi.sy")
|
||||
assert policy.is_allowed("eu.kanade.tachiyomi.extension.all.mangadex")
|
||||
|
||||
def test_allowed_prefix_matches_whole_labels_only(self) -> None:
|
||||
"""A bare string prefix would allow an unrelated package."""
|
||||
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
|
||||
assert not policy.is_allowed("eu.kanade.tachiyomisomething")
|
||||
|
||||
def test_a_day_only_prefix_is_blocked_during_curfew(self) -> None:
|
||||
"""The night list narrows the prefixes too, not just the packages."""
|
||||
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
|
||||
assert policy.is_allowed("eu.kanade.tachiyomi.sy")
|
||||
assert not policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True)
|
||||
|
||||
def test_a_night_prefix_survives_the_curfew(self) -> None:
|
||||
"""Manga is deliberately available at night (chosen 2026-08-14)."""
|
||||
policy = _policy(
|
||||
allowed_prefixes=("eu.kanade.tachiyomi",),
|
||||
night_allowed_prefixes=("eu.kanade.tachiyomi",),
|
||||
)
|
||||
assert policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True)
|
||||
assert policy.is_allowed(
|
||||
"eu.kanade.tachiyomi.extension.all.mangadex",
|
||||
during_curfew=True,
|
||||
)
|
||||
|
||||
def test_night_prefixes_must_be_a_subset_of_day_prefixes(self) -> None:
|
||||
"""The curfew tightens the day policy; it must never widen it."""
|
||||
with pytest.raises(PolicyError, match="night_allowed_prefixes"):
|
||||
_policy(night_allowed_prefixes=("eu.kanade.tachiyomi",))
|
||||
|
||||
def test_is_curfew_active_without_window_is_false(self) -> None:
|
||||
"""A policy with no curfew is never in curfew."""
|
||||
assert not _policy().is_curfew_active(time(23, 30))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user