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
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""Load the focus-mode policy from the shell config that already exists.
|
|
|
|
``phone_focus_mode/config.sh`` is the live source of truth for the rooted
|
|
enforcer. Rather than copy its lists into Python — where they would silently
|
|
drift the first time either side is edited — this module parses them.
|
|
|
|
Only simple ``export NAME="..."`` assignments are read. This is not a shell
|
|
interpreter: it deliberately understands the narrow subset ``config.sh`` uses
|
|
for policy, and raises rather than guessing at anything else.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import time
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.focus_policy.model import (
|
|
CurfewWindow,
|
|
FocusPolicy,
|
|
HomeLocation,
|
|
PolicyError,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
# Matches `export NAME="value"` / `NAME='value'` / `NAME=bare`, including the
|
|
# multi-line quoted blocks config.sh uses for its package lists.
|
|
_ASSIGNMENT = re.compile(
|
|
r"^\s*(?:export\s+|readonly\s+)*(?P<name>[A-Za-z_][A-Za-z0-9_]*)="
|
|
r"(?P<value>\"[^\"]*\"|'[^']*'|\S*)",
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
|
|
_HHMM_LEN = 4
|
|
|
|
# A quoted value needs at least the opening and closing quote characters.
|
|
_MIN_QUOTED_LEN = 2
|
|
|
|
|
|
def parse_shell_assignments(text: str) -> dict[str, str]:
|
|
"""Return every simple assignment in ``text`` as a name -> value mapping.
|
|
|
|
Later assignments win, matching shell semantics where re-assigning a name
|
|
overwrites the earlier value.
|
|
"""
|
|
values: dict[str, str] = {}
|
|
for match in _ASSIGNMENT.finditer(text):
|
|
raw = match.group("value")
|
|
if (
|
|
raw[:1] in {'"', "'"}
|
|
and raw[-1:] == raw[:1]
|
|
and len(raw) >= _MIN_QUOTED_LEN
|
|
):
|
|
raw = raw[1:-1]
|
|
values[match.group("name")] = raw
|
|
return values
|
|
|
|
|
|
def parse_package_list(value: str) -> frozenset[str]:
|
|
"""Return the non-comment, non-blank entries of a whitespace-separated list."""
|
|
return frozenset(
|
|
line.strip()
|
|
for line in value.splitlines()
|
|
if line.strip() and not line.lstrip().startswith("#")
|
|
)
|
|
|
|
|
|
def parse_hhmm(value: str, *, field_name: str) -> time:
|
|
"""Parse a bare ``HHMM`` string as used by ``NIGHT_CURFEW_START``/``_END``."""
|
|
digits = value.strip()
|
|
if len(digits) != _HHMM_LEN or not digits.isdigit():
|
|
msg = f"{field_name} must be 4 digits (HHMM), got {value!r}"
|
|
raise PolicyError(msg)
|
|
hour, minute = int(digits[:2]), int(digits[2:])
|
|
try:
|
|
return time(hour=hour, minute=minute)
|
|
except ValueError as exc:
|
|
msg = f"{field_name} is not a valid time: {value!r}"
|
|
raise PolicyError(msg) from exc
|
|
|
|
|
|
def _require(values: dict[str, str], name: str) -> str:
|
|
"""Return ``values[name]`` or raise a PolicyError naming the missing key."""
|
|
try:
|
|
return values[name]
|
|
except KeyError as exc:
|
|
msg = f"required setting {name} is missing"
|
|
raise PolicyError(msg) from exc
|
|
|
|
|
|
def _coordinate(values: dict[str, str], name: str) -> float:
|
|
"""Return a coordinate, rejecting the shipped ``REDACTED_*`` placeholders.
|
|
|
|
``config_secrets.sh`` ships with placeholder coordinates so the real home
|
|
location never lands in git. Parsing one as a float raises, but the failure
|
|
reads as a generic type error; callers need to be told the file was never
|
|
filled in. Defaulting to 0.0 would be worse still — it silently relocates
|
|
"home" to the Atlantic and disables the location gate entirely.
|
|
"""
|
|
raw = _require(values, name).strip()
|
|
try:
|
|
return float(raw)
|
|
except ValueError as exc:
|
|
msg = (
|
|
f"{name} is {raw!r}, not a coordinate. Set the real value in "
|
|
"config_secrets.sh (untracked); the shipped file is a placeholder."
|
|
)
|
|
raise PolicyError(msg) from exc
|
|
|
|
|
|
def _float_setting(values: dict[str, str], name: str, default: float) -> float:
|
|
"""Return a numeric setting, falling back to ``default`` when unset."""
|
|
raw = values.get(name)
|
|
if raw is None or not raw.strip():
|
|
return default
|
|
try:
|
|
return float(raw)
|
|
except ValueError as exc:
|
|
msg = f"{name} must be numeric, got {raw!r}"
|
|
raise PolicyError(msg) from exc
|
|
|
|
|
|
def load_policy(
|
|
config_path: Path,
|
|
secrets_path: Path | None = None,
|
|
) -> FocusPolicy:
|
|
"""Build a :class:`FocusPolicy` from ``config.sh`` and its secrets file.
|
|
|
|
``HOME_LAT``/``HOME_LON`` live in ``config_secrets.sh``, which is untracked
|
|
because it pins down where the user lives. It is read from a separate path
|
|
and never embedded in this repository.
|
|
"""
|
|
values = parse_shell_assignments(config_path.read_text(encoding="utf-8"))
|
|
|
|
if secrets_path is None:
|
|
secrets_path = config_path.parent / "config_secrets.sh"
|
|
if secrets_path.is_file():
|
|
values.update(parse_shell_assignments(secrets_path.read_text(encoding="utf-8")))
|
|
|
|
home = HomeLocation(
|
|
latitude=_coordinate(values, "HOME_LAT"),
|
|
longitude=_coordinate(values, "HOME_LON"),
|
|
radius_m=_float_setting(values, "RADIUS", 150.0),
|
|
hysteresis_m=_float_setting(values, "HYSTERESIS", 30.0),
|
|
)
|
|
|
|
curfew: CurfewWindow | None = None
|
|
if values.get("NIGHT_CURFEW_ENABLED", "0").strip() == "1":
|
|
curfew = CurfewWindow(
|
|
start=parse_hhmm(
|
|
_require(values, "NIGHT_CURFEW_START"),
|
|
field_name="NIGHT_CURFEW_START",
|
|
),
|
|
end=parse_hhmm(
|
|
_require(values, "NIGHT_CURFEW_END"),
|
|
field_name="NIGHT_CURFEW_END",
|
|
),
|
|
)
|
|
|
|
launcher = values.get("LAUNCHER_PACKAGE", "").strip() or None
|
|
|
|
return FocusPolicy(
|
|
home=home,
|
|
allowed_packages=parse_package_list(_require(values, "WHITELIST")),
|
|
night_allowed_packages=parse_package_list(values.get("NIGHT_WHITELIST", "")),
|
|
never_disable_prefixes=tuple(
|
|
sorted(parse_package_list(values.get("SYSTEM_NEVER_DISABLE", "")))
|
|
),
|
|
workout_unblock_domains=parse_package_list(
|
|
values.get("WORKOUT_UNBLOCK_DOMAINS", "")
|
|
),
|
|
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", "")))
|
|
),
|
|
)
|