diff --git a/docs/superpowers/evidence/always-blocked-geofence-exempt-2026-08-10.json b/docs/superpowers/evidence/always-blocked-geofence-exempt-2026-08-10.json new file mode 100644 index 00000000..e527ddbc --- /dev/null +++ b/docs/superpowers/evidence/always-blocked-geofence-exempt-2026-08-10.json @@ -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." + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt index c52a4f2d..bb7e4051 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt @@ -36,6 +36,16 @@ data class EnforcementDecision( val packagesToHide: Set, val packagesToShow: Set, ) { + /** + * 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() val show = mutableSetOf() 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) diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt index c7f457d8..ee5b76ae 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt @@ -72,6 +72,15 @@ data class FocusPolicy( * behaviour. */ val blockableSystemPackages: Set = 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 = 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"), diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt index a8196283..232aa3df 100644 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt +++ b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt @@ -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( diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json index 49929bf3..91f410e9 100644 --- a/focus_owner/assets/policy.json +++ b/focus_owner/assets/policy.json @@ -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", diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 2169d4db..4a967849 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -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 diff --git a/python_pkg/focus_policy/export.py b/python_pkg/focus_policy/export.py index 376002a9..b757dae0 100644 --- a/python_pkg/focus_policy/export.py +++ b/python_pkg/focus_policy/export.py @@ -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}, + ), } diff --git a/python_pkg/focus_policy/tests/test_export.py b/python_pkg/focus_policy/tests/test_export.py index 3b87a320..a8c09249 100644 --- a/python_pkg/focus_policy/tests/test_export.py +++ b/python_pkg/focus_policy/tests/test_export.py @@ -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())