testsAndMisc/python_pkg/focus_policy/model.py
Krzysztof kuhy Rudnicki f636d2b21b Make focus policy and blocklist usable without root; fix workout CDN unblock
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.

python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.

blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.

strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.

focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.

Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 09:15:02 +02:00

185 lines
6.8 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 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)
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)
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 any(
package == prefix or package.startswith(f"{prefix}.")
for prefix in 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.
"""
if self.is_protected(package):
return True
allowed = (
self.night_allowed_packages if during_curfew else self.allowed_packages
)
return package in allowed
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)
)