mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +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
256 lines
9.4 KiB
Python
256 lines
9.4 KiB
Python
"""Tests for the JSON policy exporter.
|
|
|
|
The guarantee under test is the one that cannot be recovered from on the device
|
|
itself: the enforcing app must never be rendered into a policy that would hide
|
|
it. Everything else here is shape, not safety.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from python_pkg.focus_policy.export import (
|
|
ENFORCER_PACKAGE,
|
|
policy_to_dict,
|
|
policy_to_json,
|
|
)
|
|
from python_pkg.focus_policy.model import CurfewWindow, FocusPolicy, HomeLocation
|
|
|
|
_DEFAULT_CURFEW = CurfewWindow(start=dt.time(23, 0), end=dt.time(5, 0))
|
|
|
|
|
|
def _policy(
|
|
*,
|
|
# The launcher must be allowed or the model rejects the policy outright.
|
|
allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
|
|
night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
|
|
curfew: CurfewWindow | None = _DEFAULT_CURFEW,
|
|
never_disable_prefixes: tuple[str, ...] = ("com.android.",),
|
|
allowed_prefixes: tuple[str, ...] = (),
|
|
) -> FocusPolicy:
|
|
"""Build a policy with the enforcer deliberately absent from both lists."""
|
|
return FocusPolicy(
|
|
home=HomeLocation(
|
|
latitude=52.2297,
|
|
longitude=21.0122,
|
|
radius_m=150.0,
|
|
hysteresis_m=30.0,
|
|
),
|
|
curfew=curfew,
|
|
launcher_package="com.launcher",
|
|
allowed_packages=allowed_packages,
|
|
allowed_prefixes=allowed_prefixes,
|
|
night_allowed_packages=night_allowed_packages,
|
|
never_disable_prefixes=never_disable_prefixes,
|
|
workout_unblock_domains=frozenset({"youtube.com"}),
|
|
browser_packages=frozenset({"org.mozilla.firefox"}),
|
|
)
|
|
|
|
|
|
def test_enforcer_is_injected_into_both_allowlists() -> None:
|
|
"""The app must be allowed even when the source config omits it.
|
|
|
|
Regression guard for the on-device finding of 2026-08-09: the enforcer was
|
|
in neither allowlist, so the decision layer placed it in the hide set on
|
|
every enforcing pass, leaving one in-app check as the sole thing preventing
|
|
the app from hiding itself.
|
|
"""
|
|
payload = policy_to_dict(_policy())
|
|
|
|
assert ENFORCER_PACKAGE in payload["allowed_packages"]
|
|
assert ENFORCER_PACKAGE in payload["night_allowed_packages"]
|
|
|
|
|
|
def test_enforcer_is_not_duplicated_when_already_present() -> None:
|
|
"""Injection is a set union, so an explicit entry stays single."""
|
|
payload = policy_to_dict(
|
|
_policy(
|
|
allowed_packages=frozenset({"pl.mbank", "com.launcher", ENFORCER_PACKAGE}),
|
|
night_allowed_packages=frozenset({"com.launcher", ENFORCER_PACKAGE}),
|
|
),
|
|
)
|
|
|
|
assert payload["allowed_packages"].count(ENFORCER_PACKAGE) == 1
|
|
assert payload["night_allowed_packages"].count(ENFORCER_PACKAGE) == 1
|
|
|
|
|
|
def test_blockable_system_packages_names_the_distraction_apps() -> None:
|
|
"""The sweep is default-deny for system apps; this is the opt-in.
|
|
|
|
YouTube and Chrome ship with FLAG_SYSTEM, so without this list the Device
|
|
Owner sweep skips exactly the apps it exists to block. Play is included
|
|
because leaving it reachable makes every other removal a one-tap undo.
|
|
"""
|
|
blockable = policy_to_dict(_policy())["blockable_system_packages"]
|
|
|
|
assert "com.google.android.youtube" in blockable
|
|
assert "com.android.chrome" in blockable
|
|
assert "com.android.vending" in blockable
|
|
|
|
|
|
def test_an_allowed_system_package_stays_sweepable() -> None:
|
|
"""An allowed system package must stay in the sweep, or it freezes.
|
|
|
|
Sweepable is not the same as hideable. This list decides which FLAG_SYSTEM
|
|
packages are *eligible for a decision*; ``is_allowed`` is what then
|
|
protects them. ``sweepablePackages`` in the Kotlin runner drops any system
|
|
package absent from here, so an allowed-but-unsweepable package appears in
|
|
neither ``packagesToHide`` nor ``packagesToShow`` and keeps whatever state
|
|
it was last left in.
|
|
|
|
Measured on device: Play was hidden, so subtracting the allowlist here
|
|
would have stranded it hidden permanently the moment it was allowlisted.
|
|
"""
|
|
payload = policy_to_dict(
|
|
_policy(
|
|
allowed_packages=frozenset(
|
|
{"pl.mbank", "com.launcher", "com.android.vending"},
|
|
),
|
|
),
|
|
)
|
|
|
|
assert "com.android.vending" in payload["blockable_system_packages"]
|
|
assert "com.android.vending" in payload["allowed_packages"]
|
|
|
|
|
|
def test_always_blocked_exempts_youtube_and_chrome_from_the_geofence() -> None:
|
|
"""The geofence must not become an off switch for these.
|
|
|
|
Everything else is restored on the AWAY branch, which makes leaving the
|
|
house a way to switch enforcement off -- the specific thing Device Owner
|
|
was provisioned to remove. Play is deliberately absent: it stays geofenced
|
|
so apps can still be installed away from home.
|
|
"""
|
|
always = policy_to_dict(_policy())["always_blocked_packages"]
|
|
|
|
assert "com.google.android.youtube" in always
|
|
assert "com.google.android.apps.youtube.music" in always
|
|
assert "com.android.chrome" in always
|
|
assert "com.android.vending" not in always
|
|
|
|
|
|
def test_always_on_vpn_requires_a_sweep_protected_provider() -> None:
|
|
"""Pinning a package the enforcer can hide is the worst failure mode.
|
|
|
|
DISALLOW_CONFIG_VPN pointing at a hidden app costs general connectivity,
|
|
not just the filter, so the field is emitted only when the provider is
|
|
protected.
|
|
"""
|
|
unprotected = policy_to_dict(_policy())["always_on_vpn_package"]
|
|
protected = policy_to_dict(
|
|
_policy(never_disable_prefixes=("com.celzero.bravedns",)),
|
|
)["always_on_vpn_package"]
|
|
|
|
assert unprotected == ""
|
|
assert protected == "com.celzero.bravedns"
|
|
|
|
|
|
def test_private_dns_host_is_exported_for_pinning() -> None:
|
|
"""The domain rules live on this resolver, not in the VPN app.
|
|
|
|
Measured on 2026-08-11: RethinkDNS's blocklists can be switched off from
|
|
inside that app in a few taps, and no device owner API prevents it. A
|
|
pinned Private DNS host moves the rules somewhere the phone cannot edit.
|
|
"""
|
|
payload = policy_to_dict(_policy())
|
|
|
|
# Empty until the resolver is reachable from the phone: pinning an
|
|
# unreachable host means no DNS at all, and Android refuses it anyway
|
|
# (PRIVATE_DNS_SET_ERROR_HOST_NOT_SERVING).
|
|
assert payload["private_dns_host"] == ""
|
|
assert payload["vpn_lockdown"] is True
|
|
|
|
|
|
def test_always_blocked_is_a_subset_of_blockable() -> None:
|
|
"""An always-blocked system app the sweep cannot see would silently no-op.
|
|
|
|
The runner filters FLAG_SYSTEM packages out of ``installedPackages``
|
|
unless they are opted in, so the decision layer would never receive them.
|
|
"""
|
|
payload = policy_to_dict(_policy())
|
|
|
|
assert set(payload["always_blocked_packages"]) <= set(
|
|
payload["blockable_system_packages"],
|
|
)
|
|
|
|
|
|
def test_always_blocked_never_contradicts_the_allowlist() -> None:
|
|
"""Allowing a package wins over always-blocking it.
|
|
|
|
The asset must not state both at once; the subtraction keeps the two
|
|
fields consistent no matter what config.sh says.
|
|
"""
|
|
payload = policy_to_dict(
|
|
_policy(
|
|
allowed_packages=frozenset(
|
|
{"pl.mbank", "com.launcher", "com.android.chrome"},
|
|
),
|
|
),
|
|
)
|
|
|
|
assert "com.android.chrome" not in payload["always_blocked_packages"]
|
|
assert "com.android.chrome" in payload["allowed_packages"]
|
|
|
|
|
|
def test_a_prefix_allowance_also_wins_over_always_blocking() -> None:
|
|
"""Allowance is no longer exact-only, so the check must not be either.
|
|
|
|
Subtracting just ``allowed_packages`` left a package allowed by prefix
|
|
still emitted as always-blocked, so the asset stated both at once and the
|
|
runner's behaviour depended on which check it applied first.
|
|
"""
|
|
payload = policy_to_dict(
|
|
_policy(
|
|
allowed_packages=frozenset({"pl.mbank", "com.launcher"}),
|
|
allowed_prefixes=("com.android.chrome",),
|
|
),
|
|
)
|
|
|
|
assert "com.android.chrome" not in payload["always_blocked_packages"]
|
|
|
|
|
|
def test_the_real_distraction_apps_stay_always_blocked() -> None:
|
|
"""The filter must not quietly un-block what the app exists to block."""
|
|
payload = policy_to_dict(_policy())
|
|
|
|
assert "com.google.android.youtube" in payload["always_blocked_packages"]
|
|
assert "com.android.chrome" in payload["always_blocked_packages"]
|
|
|
|
|
|
def test_lists_are_sorted_for_stable_diffs() -> None:
|
|
"""Sorted output keeps a committed asset's diff meaningful."""
|
|
payload = policy_to_dict(_policy())
|
|
|
|
for key in (
|
|
"allowed_packages",
|
|
"night_allowed_packages",
|
|
"never_disable_prefixes",
|
|
"workout_unblock_domains",
|
|
"browser_packages",
|
|
):
|
|
assert payload[key] == sorted(payload[key])
|
|
|
|
|
|
def test_curfew_is_rendered_as_wall_clock_strings() -> None:
|
|
"""Kotlin parses `HH:MM`, not an ISO time."""
|
|
assert policy_to_dict(_policy())["curfew"] == {"start": "23:00", "end": "05:00"}
|
|
|
|
|
|
def test_absent_curfew_renders_as_null() -> None:
|
|
"""A policy with no curfew must not fabricate one."""
|
|
assert policy_to_dict(_policy(curfew=None))["curfew"] is None
|
|
|
|
|
|
def test_redact_home_blanks_coordinates_but_keeps_geometry() -> None:
|
|
"""A committed asset discloses the radius, never the location."""
|
|
payload = policy_to_json(_policy(), redact_home=True)
|
|
|
|
assert '"latitude": null' in payload
|
|
assert '"longitude": null' in payload
|
|
assert '"radius_m": 150.0' in payload
|
|
|
|
|
|
def test_coordinates_survive_when_not_redacted() -> None:
|
|
"""The provisioning path still needs the real values."""
|
|
assert '"latitude": 52.2297' in policy_to_json(_policy())
|