mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-14 11:10:51 +01:00
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:
parent
6bf7343c0f
commit
b1de6f8d38
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user