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