testsAndMisc/docs/superpowers/evidence/focus-owner-geofence-and-observability-2026-08-14.json
Krzysztof kuhy Rudnicki 82e206116a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
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
2026-08-14 13:43:05 +02:00

70 lines
7.9 KiB
JSON

{
"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."
]
}