mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21:47:02 +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
217 lines
8.3 KiB
Python
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)
|
|
)
|