mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:07:03 +01:00
Leaving the house was an off switch. The AWAY branch returned early with packagesToShow = every installed package, so walking 150m from home restored YouTube -- which is the exact affordance device owner was provisioned to remove. always_blocked_packages is exempt from all four branches: AWAY hides them, the allowlist loop cannot show them, and the workout release skips them, because otherwise "go for a run" is the off switch instead. Chrome is included as a second route to the same content; Firefox stays, and carries the uBlock filters. Play is deliberately left geofenced so apps can still be installed away from home -- and a hidden package cannot be reinstalled from Play anyway. infakt moves from the allowlist to SYSTEM_NEVER_DISABLE. Allowlisting only held while it stayed in BOTH the day and night lists, and dropping it from either would silently make it hideable; isAllowed() consults the never-disable list before the curfew split, so it now holds under every condition. It is device-paired to a bank over SMS, so losing it strands the same re-authentication chain a hidden Messages app would. The exporter now refuses an always-blocked package that is not also blockable: the runner filters unopted system packages out of installedPackages, so such an entry would never be hidden and nothing would say so. Verified: 128 Python tests at 100% branch coverage, 16 Kotlin tests, the new ones canary-checked by breaking an assertion and confirming the suite actually fails. On device, youtube/music/chrome/vending are hidden=true, infakt hidden=false, uninstall still refused. The AWAY path itself is unit-tested but NOT verified on hardware: with no home_location.json every pass decides LOCATION_UNKNOWN, so the branch is unreachable on this phone until coordinates can be provisioned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
141 lines
6.0 KiB
Python
141 lines
6.0 KiB
Python
"""Render a :class:`FocusPolicy` into a backend-neutral JSON document.
|
|
|
|
The Device Owner app is written in Kotlin and cannot read ``config.sh`` or
|
|
import this package. It reads this JSON instead, so both enforcement backends
|
|
stay driven by one policy definition.
|
|
|
|
Sorted keys and sorted lists keep the output stable, so committing a rendered
|
|
policy produces a meaningful diff when the policy actually changes rather than
|
|
noise from set iteration order.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.focus_policy.model import FocusPolicy
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
# The enforcing app itself, which must never appear in the hide set. Hiding it
|
|
# removes both the escape hatch and the control that triggers the next pass,
|
|
# leaving no way back short of a factory reset. The Kotlin runner does check
|
|
# this at the point of use, but that check is the *only* protection unless the
|
|
# package is also allowed here -- so it is injected into the exported allowlists
|
|
# rather than left to a hand-edit of the rendered asset.
|
|
ENFORCER_PACKAGE = "com.kuhy.focus_owner"
|
|
|
|
# System apps the sweep is allowed to hide, named one by one.
|
|
#
|
|
# The Kotlin runner skips every package with FLAG_SYSTEM, which means the
|
|
# Device Owner path cannot touch the apps most worth blocking: YouTube ships
|
|
# at /product/app/YouTube as a system app, and so does Chrome. Simply dropping
|
|
# that filter is not the fix -- measured on the Pixel 6a, it would expose 320
|
|
# system packages of which 243 match no allowlist entry and no
|
|
# never_disable_prefix, including com.android.cellbroadcastreceiver (emergency
|
|
# alerts), com.android.credentialmanager and com.android.devicelockcontroller.
|
|
# Hiding those under Device Owner risks an unrecoverable device.
|
|
#
|
|
# So the sweep stays default-deny for system apps and this is the opt-in.
|
|
# com.android.vending is included deliberately: leaving Play reachable makes
|
|
# every other removal a one-tap undo.
|
|
BLOCKABLE_SYSTEM_PACKAGES = frozenset(
|
|
{
|
|
"com.android.chrome",
|
|
"com.android.vending",
|
|
"com.google.android.apps.youtube.music",
|
|
"com.google.android.videos",
|
|
"com.google.android.youtube",
|
|
},
|
|
)
|
|
|
|
# Packages hidden everywhere, regardless of location, curfew or workout.
|
|
#
|
|
# The geofence exists so the phone becomes usable again away from home, but
|
|
# that makes leaving the house an off switch -- which is the specific thing
|
|
# Device Owner was provisioned to remove. These are exempt from it: the
|
|
# decision layer never puts them in packagesToShow, so the AWAY branch cannot
|
|
# restore them.
|
|
#
|
|
# Chrome is here because it is a second route to the same content, not because
|
|
# browsing is banned -- Firefox stays available and carries the uBlock filters.
|
|
# com.android.vending is deliberately NOT here: Play stays geofenced so apps
|
|
# can still be installed and updated away from home, and a hidden package
|
|
# cannot be reinstalled from Play anyway.
|
|
ALWAYS_BLOCKED_PACKAGES = frozenset(
|
|
{
|
|
"com.android.chrome",
|
|
"com.google.android.apps.youtube.music",
|
|
"com.google.android.youtube",
|
|
},
|
|
)
|
|
|
|
# Every always-blocked package here is preinstalled, so it only ever reaches
|
|
# the sweep by also being opted in above. Listing one without the other would
|
|
# not error -- it would silently never be hidden, because the runner filters it
|
|
# out of installedPackages before the decision layer sees it.
|
|
_UNSWEEPABLE = ALWAYS_BLOCKED_PACKAGES - BLOCKABLE_SYSTEM_PACKAGES
|
|
if _UNSWEEPABLE: # pragma: no cover - guards a constant, not a code path
|
|
msg = (
|
|
"always-blocked system packages must also be blockable, or the sweep "
|
|
f"never sees them: {sorted(_UNSWEEPABLE)}"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
|
|
def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
|
|
"""Return a JSON-serialisable representation of ``policy``."""
|
|
curfew: dict[str, str] | None = None
|
|
if policy.curfew is not None:
|
|
curfew = {
|
|
"start": policy.curfew.start.strftime("%H:%M"),
|
|
"end": policy.curfew.end.strftime("%H:%M"),
|
|
}
|
|
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"home": {
|
|
"latitude": policy.home.latitude,
|
|
"longitude": policy.home.longitude,
|
|
"radius_m": policy.home.radius_m,
|
|
"hysteresis_m": policy.home.hysteresis_m,
|
|
},
|
|
"curfew": curfew,
|
|
"launcher_package": policy.launcher_package,
|
|
"allowed_packages": sorted({*policy.allowed_packages, ENFORCER_PACKAGE}),
|
|
"night_allowed_packages": sorted(
|
|
{*policy.night_allowed_packages, ENFORCER_PACKAGE},
|
|
),
|
|
"never_disable_prefixes": sorted(policy.never_disable_prefixes),
|
|
"workout_unblock_domains": sorted(policy.workout_unblock_domains),
|
|
"browser_packages": sorted(policy.browser_packages),
|
|
# Absent from an older asset, so the Kotlin loader must treat a
|
|
# missing key as the empty set -- i.e. keep today's behaviour of
|
|
# never touching a system app.
|
|
"blockable_system_packages": sorted(
|
|
BLOCKABLE_SYSTEM_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE},
|
|
),
|
|
# Same subtraction, for the same reason: an allowed package must never
|
|
# also be declared always-blocked, or the asset would state both at
|
|
# once. The enforcer itself can never appear here -- hiding it takes
|
|
# the escape hatch with it.
|
|
"always_blocked_packages": sorted(
|
|
ALWAYS_BLOCKED_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE},
|
|
),
|
|
}
|
|
|
|
|
|
def policy_to_json(policy: FocusPolicy, *, redact_home: bool = False) -> str:
|
|
"""Return ``policy`` as pretty-printed JSON.
|
|
|
|
``redact_home`` blanks the coordinates so a rendered policy can be attached
|
|
to a bug report or committed as a fixture without disclosing where the user
|
|
lives. The radius and hysteresis are kept, since they carry no location.
|
|
"""
|
|
payload = policy_to_dict(policy)
|
|
if redact_home:
|
|
payload["home"] = dict(payload["home"], latitude=None, longitude=None)
|
|
return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)
|