Take the geofence out of YouTube's reach, and pin infakt on

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-10 21:28:25 +02:00
parent 6bf7343c0f
commit b1de6f8d38
8 changed files with 266 additions and 5 deletions

View File

@ -0,0 +1,52 @@
{
"intent": "Exempt YouTube, YouTube Music and Chrome from the geofence so they stay hidden everywhere, and make pl.infakt.infakt unhideable under every condition rather than merely allowlisted.",
"scope": [
"python_pkg/focus_policy/export.py",
"python_pkg/focus_policy/tests/test_export.py",
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt",
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt",
"focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt",
"phone_focus_mode/config.sh",
"focus_owner/assets/policy.json",
"Non-goal: network-level YouTube blocking, which the app cannot currently do at all"
],
"changes": [
"New always_blocked_packages field. The AWAY branch previously returned early with packagesToShow = everything, so leaving home restored YouTube -- the geofence was an off switch, which is what device owner exists to remove.",
"The exemption is applied in all four branches: AWAY hides them, the allowlist loop cannot show them, and the workout release skips them (otherwise 'go for a run' becomes the off switch).",
"com.android.vending deliberately NOT always-blocked: Play stays geofenced so apps can be installed away from home, and a hidden package cannot be reinstalled from Play anyway.",
"pl.infakt.infakt moved into SYSTEM_NEVER_DISABLE. Allowlisting depended on it staying in BOTH the day and night lists; isAllowed() checks the never-disable list first, before the curfew split, so it now holds unconditionally.",
"Exporter raises if an always-blocked package is not also blockable: the runner filters unopted system packages out of installedPackages, so such an entry would silently never be hidden."
],
"verification": [
{
"command": "pytest python_pkg/focus_policy/ --cov",
"result": "pass",
"evidence": "128 tests, 100% branch coverage on every focus_policy module (export/loader/model/blocklist/__main__)."
},
{
"command": "gradlew :app:testDebugUnitTest with a deliberately wrong assertion (canary)",
"result": "pass",
"evidence": "'16 tests completed, 1 failed' with AssertionError at the always-blocked case, proving the new tests actually execute rather than being skipped. Restored, then 16/16 pass. The pre-existing 'away from home hides nothing' test was updated rather than left passing, since it documented the hole being closed."
},
{
"command": "adb install -r over the device owner; run enforcement pass",
"result": "pass",
"evidence": "Success, Device Owner Type: 0 preserved. 'applied LOCATION_UNKNOWN: hid 0, restored 0' -- correctly a no-op, the packages were already hidden by the earlier pass."
},
{
"command": "dumpsys on the always-blocked set and on infakt",
"result": "pass",
"evidence": "youtube, youtube.music, chrome, vending all hidden=true. pl.infakt.infakt hidden=false. adb uninstall still returns DELETE_FAILED_DEVICE_POLICY_MANAGER."
}
],
"risks": [
"The AWAY exemption could NOT be verified on device: with no home_location.json every pass decides LOCATION_UNKNOWN, so the AWAY branch is unreachable on this phone today. It is covered by unit tests (canary-verified) but not by a real geofence transition. That verification has to wait for the in-app coordinate writer.",
"Because of the same missing coordinates, the user's 'other apps are fine' half is NOT yet delivered: everything is still hidden everywhere, not only the always-blocked set. The in-app 'set home to current location' action is now on the critical path for that.",
"isEnforcing is derived from packagesToHide.isNotEmpty(), so the always-blocked set keeps it true under AWAY and the uninstall block stays on. This is relied upon rather than stated as a separate flag; a future change making AWAY report not-enforcing would silently make `adb uninstall` reachable away from home. A test pins it.",
"The user's most important request -- blocking all YouTube hosts/URLs at the network level -- is NOT implemented. focus_owner does no network blocking of any kind. RethinkDNS is installed but at first-run state after the wipe (its 8 wildcard rules are gone) and is itself currently hidden by enforcement."
],
"rollback": [
"git revert; regenerate the asset; rebuild; install in place. The extra asset fields are ignored by an older build (optionalStringSet reads a missing key as empty).",
"To unblock one of the always-blocked apps without a revert: remove it from ALWAYS_BLOCKED_PACKAGES, regenerate, rebuild, install, run a pass."
]
}

View File

@ -36,6 +36,16 @@ data class EnforcementDecision(
val packagesToHide: Set<String>,
val packagesToShow: Set<String>,
) {
/**
* Whether this pass leaves anything hidden.
*
* Derived rather than stored, which matters for the always-blocked set:
* AWAY still hides those, so it still counts as enforcing, and the
* uninstall block keyed off this stays on away from home. Were this tied
* to the reason instead, leaving the house would lift the block while
* YouTube stayed hidden -- making `adb uninstall` the off switch for the
* one thing that is meant not to have one.
*/
val isEnforcing: Boolean get() = packagesToHide.isNotEmpty()
companion object {
@ -59,11 +69,19 @@ data class EnforcementDecision(
val hasFix = lat != null && lon != null
val atHome = hasFix && isInside(policy, lat, lon, inputs.currentlyEnforcing)
// Exempt from every branch below, including AWAY and WORKOUT.
// Restricted to packages actually present, so the sets stay a
// description of this device rather than of the policy.
val alwaysBlocked = inputs.installedPackages
.intersect(policy.alwaysBlockedPackages)
.filterNot { policy.isProtected(it) }
.toSet()
if (hasFix && !atHome) {
return EnforcementDecision(
reason = EnforcementReason.AWAY,
packagesToHide = emptySet(),
packagesToShow = inputs.installedPackages,
packagesToHide = alwaysBlocked,
packagesToShow = inputs.installedPackages - alwaysBlocked,
)
}
@ -77,11 +95,20 @@ data class EnforcementDecision(
val hide = mutableSetOf<String>()
val show = mutableSetOf<String>()
for (pkg in inputs.installedPackages) {
if (policy.isAllowed(pkg, duringCurfew)) show.add(pkg) else hide.add(pkg)
if (pkg in alwaysBlocked) {
hide.add(pkg)
} else if (policy.isAllowed(pkg, duringCurfew)) {
show.add(pkg)
} else {
hide.add(pkg)
}
}
if (inputs.workoutActive) {
for (pkg in policy.workoutExemptPackages) {
// A workout exemption must not become a way to reach the
// always-blocked set, or "go for a run" is the off switch.
if (pkg in alwaysBlocked) continue
if (hide.remove(pkg)) show.add(pkg)
}
return EnforcementDecision(EnforcementReason.WORKOUT, hide, show)

View File

@ -72,6 +72,15 @@ data class FocusPolicy(
* behaviour.
*/
val blockableSystemPackages: Set<String> = emptySet(),
/**
* Packages hidden everywhere, exempt from the geofence.
*
* The geofence makes leaving home an off switch, which is what device
* owner exists to remove. These stay hidden under AWAY, CURFEW, AT_HOME
* and WORKOUT alike; [EnforcementDecision] never places them in
* `packagesToShow`.
*/
val alwaysBlockedPackages: Set<String> = emptySet(),
) {
/**
* Whether a package must never be hidden.
@ -155,6 +164,7 @@ data class FocusPolicy(
neverDisablePrefixes = json.stringSet("never_disable_prefixes"),
workoutUnblockDomains = json.stringSet("workout_unblock_domains"),
blockableSystemPackages = json.optionalStringSet("blockable_system_packages"),
alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"),
curfew = curfewJson?.let {
CurfewWindow(
startMinutes = parseHhMm(it.getString("start"), "curfew.start"),

View File

@ -42,6 +42,34 @@ class EnforcementDecisionTest {
""".trimIndent(),
)
/**
* A policy that names YouTube as always-blocked and drops it from the
* allowlist, since the exporter guarantees those two never overlap.
*/
private fun policyWithAlwaysBlocked() =
FocusPolicy.parse(
"""
{
"schema_version": 1,
"home": {
"latitude": $homeLat,
"longitude": $homeLon,
"radius_m": 150.0,
"hysteresis_m": 30.0
},
"curfew": {"start":"23:00","end":"05:00"},
"launcher_package": "com.launcher",
"allowed_packages": ["com.launcher","pl.mbank","com.discord"],
"night_allowed_packages": ["com.launcher","pl.mbank"],
"never_disable_prefixes": ["com.android.settings"],
"workout_unblock_domains": ["youtube.com"],
"browser_packages": [],
"blockable_system_packages": ["com.google.android.youtube"],
"always_blocked_packages": ["com.google.android.youtube"]
}
""".trimIndent(),
)
private val installed = setOf(
"com.launcher",
"pl.mbank",
@ -54,16 +82,59 @@ class EnforcementDecisionTest {
private fun latOffset(metres: Double) = homeLat + metres / metresPerDegreeLat
@Test
fun `away from home hides nothing and shows everything`() {
fun `away from home restores everything except the always-blocked set`() {
val decision = EnforcementDecision.evaluate(
policy(),
EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
)
assertEquals(EnforcementReason.AWAY, decision.reason)
// The base policy names nothing as always-blocked, so this is the
// original behaviour: leaving home restores the whole device.
assertTrue(decision.packagesToHide.isEmpty())
assertEquals(installed, decision.packagesToShow)
}
@Test
fun `always-blocked packages stay hidden away from home`() {
val decision = EnforcementDecision.evaluate(
policyWithAlwaysBlocked(),
EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
)
assertEquals(EnforcementReason.AWAY, decision.reason)
assertEquals(setOf("com.google.android.youtube"), decision.packagesToHide)
assertFalse(decision.packagesToShow.contains("com.google.android.youtube"))
// Everything else still comes back, which is the point of the geofence.
assertTrue(decision.packagesToShow.contains("com.discord"))
}
@Test
fun `always-blocked still counts as enforcing away from home`() {
val decision = EnforcementDecision.evaluate(
policyWithAlwaysBlocked(),
EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
)
// The uninstall block is keyed off isEnforcing. If AWAY reported false
// here, leaving the house would make `adb uninstall` an off switch for
// the one set that is meant not to have one.
assertTrue(decision.isEnforcing)
}
@Test
fun `a workout does not release an always-blocked package`() {
val decision = EnforcementDecision.evaluate(
policyWithAlwaysBlocked(),
EnforcementInputs(
installed,
12 * 60,
homeLat,
homeLon,
workoutActive = true,
),
)
assertEquals(EnforcementReason.WORKOUT, decision.reason)
assertTrue(decision.packagesToHide.contains("com.google.android.youtube"))
}
@Test
fun `at home only non-allowlisted apps are hidden`() {
val decision = EnforcementDecision.evaluate(

View File

@ -88,6 +88,11 @@
"pl.plksa.portalpasazera",
"ws.xsoh.etar"
],
"always_blocked_packages": [
"com.android.chrome",
"com.google.android.apps.youtube.music",
"com.google.android.youtube"
],
"blockable_system_packages": [
"com.android.chrome",
"com.android.vending",
@ -154,7 +159,8 @@
"com.google.android.trichromelibrary",
"com.google.android.webview",
"com.mediatek",
"com.qualcomm"
"com.qualcomm",
"pl.infakt.infakt"
],
"night_allowed_packages": [
"com.azure.authenticator",

View File

@ -605,7 +605,15 @@ export BLOCKED_SYSTEM_APPS="
# --- System / essential packages that must NEVER be disabled ---
# These are matched as prefixes (startswith).
# You generally don't need to edit this list.
#
# pl.infakt.infakt is the one non-system entry. Allowlisting it is weaker:
# that depends on it staying in BOTH the day and night lists, and dropping it
# from either would silently make it hideable. It is device-paired to a bank
# over SMS, so losing access to it strands the same re-authentication chain a
# hidden Messages app would. isAllowed() checks this list first, before the
# curfew split, so it holds under every condition.
export SYSTEM_NEVER_DISABLE="
pl.infakt.infakt
com.android.launcher
com.android.launcher3
com.android.settings

View File

@ -51,6 +51,39 @@ BLOCKABLE_SYSTEM_PACKAGES = frozenset(
},
)
# 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``."""
@ -84,6 +117,13 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
"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},
),
}

View File

@ -103,6 +103,53 @@ def test_blockable_system_packages_never_contradicts_the_allowlist() -> None:
assert "com.android.chrome" 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_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_lists_are_sorted_for_stable_diffs() -> None:
"""Sorted output keeps a committed asset's diff meaningful."""
payload = policy_to_dict(_policy())