mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
The enforcer failed in both directions: nothing blocked at home, and everything blocked at the office. The comparison was never inverted -- `metres <= threshold` was always right. Three separate causes: Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a ~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed outside it, read as AWAY, and restored every app the geofence exists to hide. FINE is now declared and self-granted by the device owner, along with coarse and background, re-asserted every pass so a reinstall cannot silently drop it. There were three states but two behaviours. LOCATION_UNKNOWN applies the identical sweep to AT_HOME -- correct fail-closed design, indistinguishable from outside. And nothing ever requested a fix: the pass read whatever another app happened to cache, so at the office with no navigation running the cache was empty. Passes now acquire a fix inside a location-typed foreground service, bounded at 20 s, and treat anything older than 30 min as no fix. That window is deliberately longer than the 15-minute cadence; a shorter one marks the newest cache stale on every pass of an idle phone and blocks everything. None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty while dumpsys showed 82 alarms had fired, and `run-as` is refused on the release build device owner requires. So the pass now writes a ring-buffered JSONL record read back in-app: reason, distance vs the fence, fix age/provider/accuracy, curfew state, and every hidden app with the rule that hid it. Coordinates are never recorded -- a distance is enough to debug a geofence, and this is a home address. Also fixed along the way: - Hysteresis keyed off `isEnforcing`, which is true under AWAY too because YouTube stays hidden, so the threshold was permanently radius+hysteresis. It now keys off whether the previous pass was inside the fence. - The scheduler's curfew-boundary alarms were documented but never scheduled; both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late. - Installing over the app cancelled its pending alarms, so `adb install -r` silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED now re-arms it, verified on device without launching the app. - A wrong-typed log record threw instead of being skipped, and the exception escaped far enough to leave the page stuck busy -- which disables the release-device-owner button, the one control that must work when everything else has gone wrong. - `lib/enforcement.dart` was a second decision layer with no always-blocked concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed. It would have made the new screen report YouTube visible while it is hidden. Deleted; the UI renders the Kotlin record rather than re-deciding. - Anchoring home accepted a stale or unknown-precision fix. A wrong home offsets the fence everywhere and forever, which is what produced a 766 m reading while standing at home. Policy: Play Store is allowed by day (not during curfew) so infakt can be installed and updated; the sweep is default-deny for third-party packages, so anything installed from Play is hidden on the next at-home pass. Tachiyomi's per-source extension apks are covered by a new prefix allowlist rather than an exact list that goes stale whenever an extension is installed. `export.py` stopped subtracting the allowlist from `blockable_system_packages`. Sweepable is not hideable: that list decides which system packages are eligible for a decision, and `isAllowed` is what protects them. Removing an allowed system package from the sweep freezes it in its last state -- it would have stranded Play hidden permanently the moment it was allowlisted. Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a 0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from `pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi packages present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
212 lines
10 KiB
Python
212 lines
10 KiB
Python
"""Render a :class:`FocusPolicy` into a backend-neutral JSON document.
|
|
|
|
The Device Owner app is written in Kotlin and cannot read ``config.sh`` or
|
|
import this package. It reads this JSON instead, so both enforcement backends
|
|
stay driven by one policy definition.
|
|
|
|
Sorted keys and sorted lists keep the output stable, so committing a rendered
|
|
policy produces a meaningful diff when the policy actually changes rather than
|
|
noise from set iteration order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.focus_policy.model import FocusPolicy
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
# The enforcing app itself, which must never appear in the hide set. Hiding it
|
|
# removes both the escape hatch and the control that triggers the next pass,
|
|
# leaving no way back short of a factory reset. The Kotlin runner does check
|
|
# this at the point of use, but that check is the *only* protection unless the
|
|
# package is also allowed here -- so it is injected into the exported allowlists
|
|
# rather than left to a hand-edit of the rendered asset.
|
|
ENFORCER_PACKAGE = "com.kuhy.focus_owner"
|
|
|
|
# System apps the sweep is allowed to hide, named one by one.
|
|
#
|
|
# The Kotlin runner skips every package with FLAG_SYSTEM, which means the
|
|
# Device Owner path cannot touch the apps most worth blocking: YouTube ships
|
|
# at /product/app/YouTube as a system app, and so does Chrome. Simply dropping
|
|
# that filter is not the fix -- measured on the Pixel 6a, it would expose 320
|
|
# system packages of which 243 match no allowlist entry and no
|
|
# never_disable_prefix, including com.android.cellbroadcastreceiver (emergency
|
|
# alerts), com.android.credentialmanager and com.android.devicelockcontroller.
|
|
# Hiding those under Device Owner risks an unrecoverable device.
|
|
#
|
|
# So the sweep stays default-deny for system apps and this is the opt-in.
|
|
# com.android.vending is included deliberately: leaving Play reachable makes
|
|
# every other removal a one-tap undo.
|
|
BLOCKABLE_SYSTEM_PACKAGES = frozenset(
|
|
{
|
|
"com.android.chrome",
|
|
"com.android.vending",
|
|
"com.google.android.apps.youtube.music",
|
|
"com.google.android.videos",
|
|
"com.google.android.youtube",
|
|
},
|
|
)
|
|
|
|
# Packages hidden everywhere, regardless of location, curfew or workout.
|
|
#
|
|
# The geofence exists so the phone becomes usable again away from home, but
|
|
# that makes leaving the house an off switch -- which is the specific thing
|
|
# Device Owner was provisioned to remove. These are exempt from it: the
|
|
# decision layer never puts them in packagesToShow, so the AWAY branch cannot
|
|
# restore them.
|
|
#
|
|
# Chrome is here because it is a second route to the same content, not because
|
|
# browsing is banned -- Firefox stays available and carries the uBlock filters.
|
|
# com.android.vending is deliberately NOT here: Play stays geofenced so apps
|
|
# can still be installed and updated away from home, and a hidden package
|
|
# cannot be reinstalled from Play anyway.
|
|
ALWAYS_BLOCKED_PACKAGES = frozenset(
|
|
{
|
|
"com.android.chrome",
|
|
"com.google.android.apps.youtube.music",
|
|
"com.google.android.youtube",
|
|
},
|
|
)
|
|
|
|
# The always-on VPN provider, pinned by the device owner on every pass.
|
|
#
|
|
# This is the network-level block: package hiding stops the YouTube app, but
|
|
# only a VPN reaches youtube.com in Firefox, in a webview, or in any client
|
|
# that has not been thought of. Named here rather than hardcoded in Kotlin so
|
|
# the asset stays the single description of the policy.
|
|
ALWAYS_ON_VPN_PACKAGE = "com.celzero.bravedns"
|
|
|
|
# Whether the pinned VPN runs in lockdown mode: no traffic at all leaves the
|
|
# device unless it goes through the tunnel.
|
|
#
|
|
# This is what closes "turn the VPN off and browse freely" -- without it the
|
|
# filter is advisory. The cost is real and is accepted deliberately: if the
|
|
# VPN app breaks, the phone has no connectivity until device ownership is
|
|
# released. That is survivable only because the release path lives in the
|
|
# enforcer app itself and needs no network.
|
|
VPN_LOCKDOWN = True
|
|
|
|
# Private DNS host pinned by the device owner, or "" to leave it alone.
|
|
#
|
|
# This is where the domain rules actually live. RethinkDNS's blocklists were
|
|
# measured to be switchable off from inside that app in a few taps, and no
|
|
# device owner API can prevent it -- so the block moves to a resolver on the
|
|
# PC that the phone has no way to edit. The VPN stays pinned alongside it,
|
|
# because Private DNS does not stop a browser's built-in DoH.
|
|
# Empty until the resolver is reachable from the phone. Measured 2026-08-11:
|
|
# stunnel's log recorded zero connections from the handset during two pin
|
|
# attempts, so setGlobalPrivateDnsModeSpecifiedHost returned
|
|
# PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING -- the router does not forward 853.
|
|
# Android's `nc -z` on the device reported the port OPEN three times and was
|
|
# simply wrong; toybox nc does not implement -z, so the server-side log is the
|
|
# only trustworthy evidence here.
|
|
#
|
|
# There is a second, deeper reason not to enable this yet: while a VPN with
|
|
# lockdown holds the tunnel, app DNS goes to the VPN's own resolver rather
|
|
# than the system Private DNS path, so a successful pin would carry no block.
|
|
PRIVATE_DNS_HOST = ""
|
|
|
|
# Every always-blocked package here is preinstalled, so it only ever reaches
|
|
# the sweep by also being opted in above. Listing one without the other would
|
|
# not error -- it would silently never be hidden, because the runner filters it
|
|
# out of installedPackages before the decision layer sees it.
|
|
_UNSWEEPABLE = ALWAYS_BLOCKED_PACKAGES - BLOCKABLE_SYSTEM_PACKAGES
|
|
if _UNSWEEPABLE: # pragma: no cover - guards a constant, not a code path
|
|
msg = (
|
|
"always-blocked system packages must also be blockable, or the sweep "
|
|
f"never sees them: {sorted(_UNSWEEPABLE)}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
|
|
def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
|
|
"""Return a JSON-serialisable representation of ``policy``."""
|
|
curfew: dict[str, str] | None = None
|
|
if policy.curfew is not None:
|
|
curfew = {
|
|
"start": policy.curfew.start.strftime("%H:%M"),
|
|
"end": policy.curfew.end.strftime("%H:%M"),
|
|
}
|
|
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"home": {
|
|
"latitude": policy.home.latitude,
|
|
"longitude": policy.home.longitude,
|
|
"radius_m": policy.home.radius_m,
|
|
"hysteresis_m": policy.home.hysteresis_m,
|
|
},
|
|
"curfew": curfew,
|
|
"launcher_package": policy.launcher_package,
|
|
"allowed_packages": sorted({*policy.allowed_packages, ENFORCER_PACKAGE}),
|
|
"night_allowed_packages": sorted(
|
|
{*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 - {ENFORCER_PACKAGE},
|
|
),
|
|
# 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(
|
|
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,
|
|
# Emitted only when the provider is protected from the sweep. Pinning a
|
|
# package the enforcer can hide is the worst case: DISALLOW_CONFIG_VPN
|
|
# points at a hidden app and the device loses connectivity rather than
|
|
# just the filter.
|
|
"always_on_vpn_package": (
|
|
ALWAYS_ON_VPN_PACKAGE if policy.is_protected(ALWAYS_ON_VPN_PACKAGE) else ""
|
|
),
|
|
}
|
|
|
|
|
|
def policy_to_json(policy: FocusPolicy, *, redact_home: bool = False) -> str:
|
|
"""Return ``policy`` as pretty-printed JSON.
|
|
|
|
``redact_home`` blanks the coordinates so a rendered policy can be attached
|
|
to a bug report or committed as a fixture without disclosing where the user
|
|
lives. The radius and hysteresis are kept, since they carry no location.
|
|
"""
|
|
payload = policy_to_dict(policy)
|
|
if redact_home:
|
|
payload["home"] = dict(payload["home"], latitude=None, longitude=None)
|
|
return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)
|