testsAndMisc/python_pkg/focus_policy/model.py
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

217 lines
8.3 KiB
Python

"""Typed policy model shared by every focus-mode enforcement backend.
The rooted implementation expresses all of this as exported shell strings in
``phone_focus_mode/config.sh``. That works for shell, but it cannot be unit
tested, and a Device Owner app written in Kotlin cannot read it at all. This
module is the language-neutral source of truth; ``export_json`` renders it into
a form any backend can load.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from datetime import time
_MAX_LATITUDE = 90.0
_MAX_LONGITUDE = 180.0
_EARTH_RADIUS_M = 6_371_000.0
class PolicyError(ValueError):
"""Raised when a policy value is missing, malformed, or self-contradictory."""
@dataclass(frozen=True)
class HomeLocation:
"""The coordinate that focus mode is anchored to.
``radius_m`` is the distance at which restrictions switch on.
``hysteresis_m`` is added to the radius before restrictions switch back
*off*, so that GPS jitter at exactly the boundary cannot cause the enforcer
to flap between states many times a minute.
"""
latitude: float
longitude: float
radius_m: float = 150.0
hysteresis_m: float = 30.0
def __post_init__(self) -> None:
"""Reject coordinates and distances that cannot describe a real place."""
if not -_MAX_LATITUDE <= self.latitude <= _MAX_LATITUDE:
msg = f"latitude {self.latitude} outside [-90, 90]"
raise PolicyError(msg)
if not -_MAX_LONGITUDE <= self.longitude <= _MAX_LONGITUDE:
msg = f"longitude {self.longitude} outside [-180, 180]"
raise PolicyError(msg)
if self.radius_m <= 0:
msg = f"radius_m must be positive, got {self.radius_m}"
raise PolicyError(msg)
if self.hysteresis_m < 0:
msg = f"hysteresis_m must not be negative, got {self.hysteresis_m}"
raise PolicyError(msg)
def distance_m(self, latitude: float, longitude: float) -> float:
"""Return great-circle metres from home to the given point.
Mirrors the Haversine formula in ``focus_daemon.sh`` so that the Python
policy layer and the shell enforcer agree on the same boundary.
"""
lat1, lat2 = math.radians(self.latitude), math.radians(latitude)
delta_lat = math.radians(latitude - self.latitude)
delta_lon = math.radians(longitude - self.longitude)
haversine = (
math.sin(delta_lat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2
)
return 2 * _EARTH_RADIUS_M * math.asin(math.sqrt(haversine))
def is_inside(
self,
latitude: float,
longitude: float,
*,
currently_focused: bool,
) -> bool:
"""Return whether this point counts as "at home", honouring hysteresis.
The threshold depends on the current state: leaving requires travelling
``hysteresis_m`` further than arriving did. Without this a reading that
hovers on the radius toggles enforcement on every poll.
"""
threshold = self.radius_m + (self.hysteresis_m if currently_focused else 0.0)
return self.distance_m(latitude, longitude) <= threshold
@dataclass(frozen=True)
class CurfewWindow:
"""A nightly window that wraps midnight (e.g. 23:00 -> 05:00)."""
start: time
end: time
def contains(self, moment: time) -> bool:
"""Return whether ``moment`` falls inside the window.
Handles both same-day windows (09:00-17:00) and the wrapping windows
focus mode actually uses (23:00-05:00), where "inside" means at or after
the start *or* strictly before the end.
"""
if self.start <= self.end:
return self.start <= moment < self.end
return moment >= self.start or moment < self.end
@dataclass(frozen=True)
class FocusPolicy:
"""The complete, backend-independent focus-mode policy."""
home: HomeLocation
allowed_packages: frozenset[str]
night_allowed_packages: frozenset[str]
never_disable_prefixes: tuple[str, ...]
workout_unblock_domains: frozenset[str] = frozenset()
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."""
if self.launcher_package and self.launcher_package not in self.allowed_packages:
msg = (
f"launcher {self.launcher_package!r} is not in allowed_packages; "
"enforcing this policy would leave the device with no home screen"
)
raise PolicyError(msg)
orphans = self.night_allowed_packages - self.allowed_packages
if orphans:
msg = (
"night_allowed_packages must be a subset of allowed_packages; "
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.
Guards the system packages whose loss would brick core functions —
dialer, settings, IME, SystemUI. Prefix-matched, mirroring the shell
implementation, so that ``com.android.providers.telephony`` is covered
by the ``com.android.providers`` entry.
"""
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,
and likewise for the prefix lists.
"""
if self.is_protected(package):
return True
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,
)
def is_curfew_active(self, moment: time) -> bool:
"""Return whether the night curfew is in force at ``moment``."""
return self.curfew is not None and self.curfew.contains(moment)
def packages_to_block(
self,
installed: frozenset[str],
*,
during_curfew: bool = False,
) -> frozenset[str]:
"""Return the installed packages that this policy blocks.
Callers pass the set actually present on the device, so a package that
is not installed never appears in the result.
"""
return frozenset(
package
for package in installed
if not self.is_allowed(package, during_curfew=during_curfew)
)