testsAndMisc/python_pkg/focus_policy/tests/test_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

270 lines
11 KiB
Python

"""Tests for the policy model: geometry, curfew windows, and allow decisions."""
from __future__ import annotations
from datetime import time
import pytest
from python_pkg.focus_policy.model import (
CurfewWindow,
FocusPolicy,
HomeLocation,
PolicyError,
)
HOME = HomeLocation(latitude=52.2297, longitude=21.0122)
METRES_PER_DEGREE_LAT = 111_320.0
def _policy(
*,
allowed_packages: frozenset[str] | None = None,
night_allowed_packages: frozenset[str] | None = None,
curfew: CurfewWindow | None = None,
launcher_package: str | None = None,
allowed_prefixes: tuple[str, ...] = (),
night_allowed_prefixes: tuple[str, ...] = (),
) -> FocusPolicy:
"""Build a small policy, overriding individual fields per test."""
return FocusPolicy(
home=HOME,
allowed_packages=(
frozenset({"com.good", "pl.mbank", "com.launcher"})
if allowed_packages is None
else allowed_packages
),
night_allowed_packages=(
frozenset({"pl.mbank"})
if night_allowed_packages is None
else night_allowed_packages
),
never_disable_prefixes=("com.android.providers", "com.android.settings"),
curfew=curfew,
launcher_package=launcher_package,
allowed_prefixes=allowed_prefixes,
night_allowed_prefixes=night_allowed_prefixes,
)
class TestHomeLocation:
"""Coordinate validation and distance maths."""
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"latitude": 91.0, "longitude": 0.0}, "latitude"),
({"latitude": -91.0, "longitude": 0.0}, "latitude"),
({"latitude": 0.0, "longitude": 181.0}, "longitude"),
({"latitude": 0.0, "longitude": -181.0}, "longitude"),
({"latitude": 0.0, "longitude": 0.0, "radius_m": 0.0}, "radius_m"),
({"latitude": 0.0, "longitude": 0.0, "hysteresis_m": -1.0}, "hysteresis_m"),
],
)
def test_rejects_impossible_values(
self,
kwargs: dict[str, float],
expected: str,
) -> None:
"""Out-of-range coordinates and distances raise, naming the bad field."""
with pytest.raises(PolicyError, match=expected):
HomeLocation(**kwargs)
def test_accepts_boundary_coordinates(self) -> None:
"""The poles and the antimeridian are valid places."""
assert HomeLocation(latitude=90.0, longitude=180.0).latitude == 90.0
assert HomeLocation(latitude=-90.0, longitude=-180.0).longitude == -180.0
def test_distance_to_self_is_zero(self) -> None:
"""A point measured against itself is zero metres away."""
assert HOME.distance_m(HOME.latitude, HOME.longitude) == pytest.approx(0.0)
def test_distance_matches_known_offset(self) -> None:
"""One degree of latitude is ~111 km; used to pin the Haversine maths."""
away = HOME.distance_m(HOME.latitude + 1.0, HOME.longitude)
assert away == pytest.approx(METRES_PER_DEGREE_LAT, rel=0.01)
def test_inside_within_radius_regardless_of_state(self) -> None:
"""Well inside the radius is "home" whether or not focus is already on."""
near = HOME.latitude + 50 / METRES_PER_DEGREE_LAT
assert HOME.is_inside(near, HOME.longitude, currently_focused=False)
assert HOME.is_inside(near, HOME.longitude, currently_focused=True)
def test_hysteresis_prevents_flapping_at_the_boundary(self) -> None:
"""Between radius and radius+hysteresis the answer depends on state.
This is the whole point of hysteresis: arriving has not yet triggered
focus, but leaving has not yet released it, so GPS jitter around the
boundary cannot toggle enforcement on every poll.
"""
edge = HOME.latitude + 160 / METRES_PER_DEGREE_LAT
assert not HOME.is_inside(edge, HOME.longitude, currently_focused=False)
assert HOME.is_inside(edge, HOME.longitude, currently_focused=True)
def test_outside_hysteresis_band_is_away_in_both_states(self) -> None:
"""Beyond radius+hysteresis the user is away regardless of state."""
far = HOME.latitude + 500 / METRES_PER_DEGREE_LAT
assert not HOME.is_inside(far, HOME.longitude, currently_focused=False)
assert not HOME.is_inside(far, HOME.longitude, currently_focused=True)
class TestCurfewWindow:
"""Window containment, including the midnight wrap."""
@pytest.mark.parametrize(
("moment", "expected"),
[
(time(22, 59), False),
(time(23, 0), True),
(time(23, 59), True),
(time(0, 0), True),
(time(4, 59), True),
(time(5, 0), False),
(time(12, 0), False),
],
)
def test_wrapping_window_spans_midnight(
self,
moment: time,
*,
expected: bool,
) -> None:
"""23:00-05:00 covers the late evening and the small hours alike."""
window = CurfewWindow(start=time(23, 0), end=time(5, 0))
assert window.contains(moment) is expected
@pytest.mark.parametrize(
("moment", "expected"),
[
(time(8, 59), False),
(time(9, 0), True),
(time(16, 59), True),
(time(17, 0), False),
],
)
def test_same_day_window(self, moment: time, *, expected: bool) -> None:
"""A non-wrapping window is a plain half-open interval."""
window = CurfewWindow(start=time(9, 0), end=time(17, 0))
assert window.contains(moment) is expected
class TestFocusPolicy:
"""Allow/deny decisions and the invariants that keep the phone usable."""
def test_rejects_launcher_outside_allowlist(self) -> None:
"""A policy that blocks its own launcher would leave no home screen."""
with pytest.raises(PolicyError, match="no home screen"):
_policy(launcher_package="com.missing")
def test_accepts_launcher_inside_allowlist(self) -> None:
"""The guard permits a launcher that is actually allowed."""
assert _policy(launcher_package="com.launcher").launcher_package
def test_rejects_night_packages_absent_from_day_list(self) -> None:
"""A night allowance for a day-blocked package is contradictory."""
with pytest.raises(PolicyError, match="subset"):
_policy(night_allowed_packages=frozenset({"com.unknown"}))
@pytest.mark.parametrize(
("package", "expected"),
[
("com.android.settings", True),
("com.android.providers", True),
("com.android.providers.telephony", True),
("com.android.providersomething", False),
("com.good", False),
],
)
def test_protection_matches_whole_labels_only(
self,
package: str,
*,
expected: bool,
) -> None:
"""Prefix protection covers subpackages but not lookalike names.
``com.android.providers.telephony`` must be protected; the unrelated
``com.android.providersomething`` must not be swept in by a bare
string prefix test.
"""
assert _policy().is_protected(package) is expected
def test_protected_packages_are_allowed_even_during_curfew(self) -> None:
"""Core system packages survive the strictest state."""
policy = _policy()
assert policy.is_allowed("com.android.settings", during_curfew=True)
def test_curfew_narrows_the_allowlist(self) -> None:
"""Day-allowed apps can still be blocked at night."""
policy = _policy()
assert policy.is_allowed("com.good")
assert not policy.is_allowed("com.good", during_curfew=True)
assert policy.is_allowed("pl.mbank", during_curfew=True)
def test_unknown_package_is_blocked(self) -> None:
"""Anything not explicitly allowed is denied."""
assert not _policy().is_allowed("com.evil")
def test_allowed_prefix_covers_a_family_of_packages(self) -> None:
"""Tachiyomi ships every source as its own apk.
An exact list goes stale the moment a new extension is installed, which
looks exactly like a bug from the phone.
"""
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
assert policy.is_allowed("eu.kanade.tachiyomi")
assert policy.is_allowed("eu.kanade.tachiyomi.sy")
assert policy.is_allowed("eu.kanade.tachiyomi.extension.all.mangadex")
def test_allowed_prefix_matches_whole_labels_only(self) -> None:
"""A bare string prefix would allow an unrelated package."""
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
assert not policy.is_allowed("eu.kanade.tachiyomisomething")
def test_a_day_only_prefix_is_blocked_during_curfew(self) -> None:
"""The night list narrows the prefixes too, not just the packages."""
policy = _policy(allowed_prefixes=("eu.kanade.tachiyomi",))
assert policy.is_allowed("eu.kanade.tachiyomi.sy")
assert not policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True)
def test_a_night_prefix_survives_the_curfew(self) -> None:
"""Manga is deliberately available at night (chosen 2026-08-14)."""
policy = _policy(
allowed_prefixes=("eu.kanade.tachiyomi",),
night_allowed_prefixes=("eu.kanade.tachiyomi",),
)
assert policy.is_allowed("eu.kanade.tachiyomi.sy", during_curfew=True)
assert policy.is_allowed(
"eu.kanade.tachiyomi.extension.all.mangadex",
during_curfew=True,
)
def test_night_prefixes_must_be_a_subset_of_day_prefixes(self) -> None:
"""The curfew tightens the day policy; it must never widen it."""
with pytest.raises(PolicyError, match="night_allowed_prefixes"):
_policy(night_allowed_prefixes=("eu.kanade.tachiyomi",))
def test_is_curfew_active_without_window_is_false(self) -> None:
"""A policy with no curfew is never in curfew."""
assert not _policy().is_curfew_active(time(23, 30))
def test_is_curfew_active_with_window(self) -> None:
"""A configured window drives the curfew state."""
policy = _policy(curfew=CurfewWindow(start=time(23, 0), end=time(5, 0)))
assert policy.is_curfew_active(time(23, 30))
assert not policy.is_curfew_active(time(12, 0))
def test_packages_to_block_ignores_uninstalled(self) -> None:
"""Only packages present on the device can be blocked."""
policy = _policy()
installed = frozenset({"com.good", "com.evil", "com.android.settings"})
assert policy.packages_to_block(installed) == frozenset({"com.evil"})
def test_packages_to_block_during_curfew(self) -> None:
"""The curfew list is applied when the curfew flag is set."""
policy = _policy()
installed = frozenset({"com.good", "pl.mbank"})
assert policy.packages_to_block(installed, during_curfew=True) == frozenset(
{"com.good"},
)