From f33fd03a16623439696afea1008a3b23e84b07d8 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 10 Aug 2026 15:22:04 +0200 Subject: [PATCH] focus_owner: keep the unhide path reachable, never hide the enforcer setApplicationHidden drops a package from the default PackageManager enumeration even though it stays installed=true, so a sweep querying with flags 0 could never see anything it had already hidden. That made packagesToShow permanently empty: measured on device, a pass following one that hid three apps reported "hid 0, restored 0" and left them hidden with no way back. Query with MATCH_UNINSTALLED_PACKAGES instead. Hiding the enforcer itself is the other unrecoverable case, since it takes the escape hatch and the trigger with it. The exporter now injects com.kuhy.focus_owner into both the day and night allowlists so the decision layer cannot propose it, and the runner keeps its point-of-use skip to cover a policy asset rendered before this fix or hand-edited. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J --- .../focus-owner-restore-path-2026-08-09.json | 17 ++ .../focus-owner-restore-path-2026-08-09.json | 54 ++++++ .../com/kuhy/focus_owner/EnforcementRunner.kt | 24 ++- focus_owner/assets/policy.json | 3 + .../profile-owner-verification-2026-08-09.md | 172 ++++++++++++++++++ python_pkg/focus_policy/export.py | 14 +- python_pkg/focus_policy/tests/test_export.py | 109 +++++++++++ python_pkg/focus_policy/tests/test_loader.py | 8 +- 8 files changed, 393 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/contracts/focus-owner-restore-path-2026-08-09.json create mode 100644 docs/superpowers/evidence/focus-owner-restore-path-2026-08-09.json create mode 100644 focus_owner/docs/profile-owner-verification-2026-08-09.md create mode 100644 python_pkg/focus_policy/tests/test_export.py diff --git a/docs/superpowers/contracts/focus-owner-restore-path-2026-08-09.json b/docs/superpowers/contracts/focus-owner-restore-path-2026-08-09.json new file mode 100644 index 00000000..4e08fca4 --- /dev/null +++ b/docs/superpowers/contracts/focus-owner-restore-path-2026-08-09.json @@ -0,0 +1,17 @@ +{ + "title": "focus_owner: keep the unhide path reachable and never hide the enforcer", + "objective": "A Device Owner enforcement pass could put the device into a state it could not leave. Hidden packages drop out of the default PackageManager enumeration, so the allowlist sweep could never see them again and nothing could be un-hidden; and hiding the enforcer itself would take the escape hatch and the trigger with it. Both paths are unrecoverable from the device, so both are closed here.", + "acceptance_criteria": [ + "installedThirdPartyPackages() queries with MATCH_UNINSTALLED_PACKAGES so already-hidden packages remain visible to the sweep", + "A pass following one that hid apps reports a non-empty packagesToShow and can restore them", + "The exporter injects the enforcer package into both the day and night allowlists, so the decision layer never proposes hiding it", + "EnforcementRunner keeps the point-of-use skip for its own package, covering a policy asset rendered before this fix or hand-edited", + "python_pkg/focus_policy stays at 100% branch coverage, including the new export behaviour" + ], + "out_of_scope": [ + "Changing which third-party apps the policy blocks", + "The night-curfew schedule itself", + "Any change to Device Owner provisioning or the escape hatch mechanism" + ], + "verifier": "python -m pytest python_pkg/focus_policy --cov=python_pkg.focus_policy (123 passed, 100% branch coverage); on-device: a pass that hid three apps previously reported 'hid 0, restored 0' and left them hidden permanently — see focus_owner/docs/profile-owner-verification-2026-08-09.md" +} diff --git a/docs/superpowers/evidence/focus-owner-restore-path-2026-08-09.json b/docs/superpowers/evidence/focus-owner-restore-path-2026-08-09.json new file mode 100644 index 00000000..a171ef62 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-restore-path-2026-08-09.json @@ -0,0 +1,54 @@ +{ + "intent": "Make it possible for focus_owner to un-hide an app it has hidden. Before this change a hidden app could never be restored, so leaving home (or the curfew ending) would not have given access back.", + "scope": [ + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt", + "python_pkg/focus_policy/export.py", + "python_pkg/focus_policy/tests/test_export.py", + "python_pkg/focus_policy/tests/test_loader.py", + "focus_owner/docs/profile-owner-verification-2026-08-09.md", + "Non-goal: wiring the workout signal into the Kotlin path (pre-existing gap, documented not fixed)", + "Non-goal: Device Owner provisioning, which still needs a factory reset" + ], + "changes": [ + "EnforcementRunner.installedThirdPartyPackages() now queries with PackageManager.MATCH_UNINSTALLED_PACKAGES. A package hidden by setApplicationHidden is dropped from the default enumeration, so it left installedPackages, so it could never appear in packagesToShow, so it could never be unhidden.", + "export.py injects ENFORCER_PACKAGE (com.kuhy.focus_owner) into both allowlists. The enforcer was in neither, so the decision layer put it in the hide set on every enforcing pass; only an in-app check stopped the app hiding itself along with its own escape hatch.", + "Corrected the EnforcementRunner comment that described that in-app check as redundant defence-in-depth; until a policy asset is regenerated it is the sole protection.", + "Added test_export.py covering the injection guarantee, set-union idempotence, sorted output, curfew rendering and home redaction; updated the test_loader.py allowlist assertion the injection legitimately changes." + ], + "verification": [ + { + "command": "on-device: managed profile (user 12) with profile owner, tap 'Run enforcement now'", + "result": "fail-then-pass", + "evidence": "Before fix: 'applied LOCATION_UNKNOWN: hid 0, restored 0' with all three apps still hidden=true, and pm list packages --user 12 -3 returning only focus_owner. After fix: 'setApplicationHidden(...,false)=true' x3 and 'applied LOCATION_UNKNOWN: hid 0, restored 3'." + }, + { + "command": "adb shell dumpsys package (per-user hidden= flag)", + "result": "pass", + "evidence": "com.termux, org.breezyweather, de.duenndns.gmdice all hidden=false after the restore pass; back in the default enumeration; cmd package resolve-activity com.termux resolved to TermuxActivity." + }, + { + "command": "adb reboot; re-check hidden state in user 12", + "result": "pass", + "evidence": "All three still hidden=true after reboot. This is the property pm suspend and pm disable-user both fail, and the one the Device Owner design depends on." + }, + { + "command": "python -m pytest python_pkg/focus_policy/", + "result": "pass", + "evidence": "123 passed. Isolated coverage for python_pkg/focus_policy: 249 statements, 58 branches, 100%." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "See commit; all hooks green." + } + ], + "risks": [ + "MATCH_UNINSTALLED_PACKAGES also returns packages uninstalled with data retained, which could put ghosts in packagesToHide. Measured on this device: pm list packages -3 and -u -3 both return 49, so there are none, and apply() wraps both bridge calls in runCatching so a ghost is a no-op.", + "Verification used a profile owner, not a device owner. The DPM calls are identical, but the harness could not exercise real home coordinates (every pass resolved to LOCATION_UNKNOWN), the full 373-package sweep, or persisted geofence hysteresis.", + "The committed policy asset still predates the exporter fix; it cannot be regenerated on this machine because config_secrets.sh holds the REDACTED_LAT placeholder rather than real coordinates. Until it is regenerated the in-app self-hide check remains the only protection, which is why that check was kept." + ], + "rollback": [ + "git revert the commit. The Kotlin change is one flag argument and the exporter change is one set union.", + "After rollback, re-check that no app is left hidden: adb shell pm list packages -u -3 --user 0 against pm list packages -3 --user 0. A difference means an app is hidden and, without the fix, cannot be restored by the app itself; clear it with adb shell pm unhide or by removing the profile." + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt index 038bfa7e..8d2fda09 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt @@ -75,9 +75,12 @@ class EnforcementRunner(private val context: Context) { } } for (pkg in decision.packagesToHide) { - // Defence in depth: the policy already refuses to hide these, but - // hiding the launcher or the dialer would be unrecoverable from - // the device itself, so it is checked again at the point of use. + // The exporter injects this package into both allowlists, so the + // decision layer should never propose it. Kept as a belt-and-braces + // check because it guards the unrecoverable case: hiding the + // enforcer takes the escape hatch and the trigger with it, and a + // policy asset rendered before that fix -- or hand-edited -- would + // otherwise hide the app on the first enforcing pass. if (pkg == context.packageName) continue if (!bridge.isApplicationHidden(pkg)) { if (bridge.setApplicationHidden(pkg, true)) hidden++ @@ -90,10 +93,21 @@ class EnforcementRunner(private val context: Context) { ) } - /** Third-party packages, the only ones the allowlist sweep considers. */ + /** + * Third-party packages, the only ones the allowlist sweep considers. + * + * [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a + * package hidden by `setApplicationHidden` is dropped from the default + * enumeration entirely, even though it remains `installed=true`. Querying + * with flags `0` therefore returns a set that can never contain anything + * this app has already hidden — so [EnforcementDecision.packagesToShow] + * would come back empty and nothing could ever be unhidden again. Measured + * on device: after a pass hid three apps, the following pass reported + * `hid 0, restored 0` and left them hidden permanently. + */ private fun installedThirdPartyPackages(): Set { val pm = context.packageManager - return pm.getInstalledApplications(0) + return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES) .filter { (it.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) == 0 } .map { it.packageName } .toSet() diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json index 38bdd294..4405423d 100644 --- a/focus_owner/assets/policy.json +++ b/focus_owner/assets/policy.json @@ -28,11 +28,13 @@ "com.kuhy.diet_guard_app", "com.kuhy.dufs_client", "com.kuhy.epopeja_karta", + "com.kuhy.focus_owner", "com.kuhy.focusstatus", "com.kuhy.habit_stack", "com.kuhy.home_inventory", "com.kuhy.horatio", "com.kuhy.pomodoro_app", + "com.kuhy.untools", "com.kuhy.vaultkitbypass", "com.kuhy.wake_alarm_sync", "com.kuhy.workout_app", @@ -158,6 +160,7 @@ "com.google.android.contactkeys", "com.google.android.safetycore", "com.kuhy.diet_guard_app", + "com.kuhy.focus_owner", "com.kuhy.focusstatus", "com.kuhy.habit_stack", "com.kuhy.vaultkitbypass", diff --git a/focus_owner/docs/profile-owner-verification-2026-08-09.md b/focus_owner/docs/profile-owner-verification-2026-08-09.md new file mode 100644 index 00000000..22910cce --- /dev/null +++ b/focus_owner/docs/profile-owner-verification-2026-08-09.md @@ -0,0 +1,172 @@ +# EnforcementRunner on-device verification (profile-owner harness) + +Date: 2026-08-09. Device: Pixel 6a `23181JEGR08034`, Android 16 (SDK 36). + +The Device Owner path is still blocked on a factory reset, so this run used a +removable managed profile as a stand-in: `pm create-user --profileOf 0 +--managed` (user 12) → `dpm set-profile-owner` → `pm install-existing --user 12`. +Profile owner and device owner reach the same `DevicePolicyManager` calls, so +the wiring under test is identical; what differs is scope, recorded below. + +## Verified + +| Property | Method | Result | +| ---------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| Policy loads on device | app status screen | 84 allowed (39 night), curfew 23:00-05:00, radius 150 m, 15 workout domains | +| Enforcement pass runs end-to-end | tap "Run enforcement now" | `applied LOCATION_UNKNOWN: hid 3, restored 0` | +| Hide is real, not self-reported | `dumpsys package ` per-user `hidden=` | `com.termux`, `org.breezyweather`, `de.duenndns.gmdice` all `hidden=true` | +| Hidden app is unreachable | `am start --user 12 -n com.termux/...` | `Error type 3 ... does not exist` | +| Self-hide guard holds | same dumpsys sweep | `com.kuhy.focus_owner` stayed `hidden=false` | +| **Hide survives reboot** | `adb reboot` → re-check | all three still `hidden=true` | +| Next pass is armed | `dumpsys alarm` | `*walarm*:com.kuhy.focus_owner/.EnforcementAlarmReceiver` present | +| **Unhide restores access** | after the fix below | `restored 3`; all `hidden=false`, launchable again | +| Non-exported service rejects shell | `am start-foreground-service` | `Requires permission not exported from uid 1210324` — as designed; the UI button is the only trigger | + +The reboot result is the one that matters: it is the property the whole Device +Owner design rests on, and it is the one `pm suspend` and `pm disable-user` +both fail. + +## Not covered by this harness + +- **The geofence.** Retried after real coordinates were provisioned (see + below), and still not reachable: a freshly created work profile has its own + location cache, which starts empty and stays empty because nothing in the + profile requests a fix. `dumpsys location` showed + `user 10: last location=null` against a valid `52.228948,20.951184` fix in + user 0. `EnforcementRunner.lastKnownLocation()` therefore returned null and + every pass resolved to `LOCATION_UNKNOWN` (fail-closed) regardless. Shell + mock injection is blocked (`SecurityException: not allowed to perform +MOCK_LOCATION`) and `cmd location force-location-update` does not exist on + this build. **The AWAY branch remains unexercised on hardware** and will + only be testable under a real Device Owner in user 0. + + Note the earlier `run-as` failure was mine, not a platform limit: `run-as +--user N` works once the app has been launched in user N. Coordinates were + successfully written into the profile's `filesDir` on the second attempt. + +- **The full 373-package sweep.** The profile saw only the packages + deliberately installed into it. +- **Persisted hysteresis.** `wasEnforcing()` starts fresh in a new profile. +- **System packages.** `com.google.android.dialer` is a system app, so + `installedThirdPartyPackages()` filters it out; it did not exercise the + allowlist branch as intended. + +## The bug this run found: hidden apps could never be restored + +Testing the restore path meant widening the allowlist to cover the three test +apps and re-running a pass. Expected `restored 3`. Observed: + +``` +applied LOCATION_UNKNOWN: hid 0, restored 0 +``` + +with all three still `hidden=true`. The cause is that +`pm list packages --user 12 -3` returned _only_ `com.kuhy.focus_owner` — a +package hidden via `setApplicationHidden` is dropped from the default package +enumeration entirely, while remaining `installed=true hidden=true` and visible +under `pm list packages -u`. + +`EnforcementRunner.installedThirdPartyPackages()` called +`getInstalledApplications(0)`. So once an app was hidden it left +`installedPackages`, which meant it could never appear in +`EnforcementDecision.packagesToShow`, which meant **nothing this app hid could +ever be unhidden again** — the exact failure the show-before-hide ordering in +`apply()` exists to prevent. Leaving home would not have restored anything. + +Fix: query with `PackageManager.MATCH_UNINSTALLED_PACKAGES`. Re-verified on +device after the fix: + +``` +setApplicationHidden(de.duenndns.gmdice,false)=true +setApplicationHidden(org.breezyweather,false)=true +setApplicationHidden(com.termux,false)=true +applied LOCATION_UNKNOWN: hid 0, restored 3 +``` + +All three then read `hidden=false`, reappeared in the default enumeration, and +`cmd package resolve-activity com.termux` resolved to `TermuxActivity` again. + +The flag also admits packages that are uninstalled with data retained, which +would let `packagesToHide` contain ghosts. Measured on this device: +`pm list packages -3` and `pm list packages -u -3` both return 49, so there are +none. `apply()` wraps both bridge calls in `runCatching` regardless, so a ghost +would be a no-op rather than a crash. + +## Second finding: the self-hide guard is not redundant + +`EnforcementRunner.kt:79-80` comments the self-hide check as defence in depth — +_"the policy already refuses to hide these"_. Measured against the committed +policy, it does not: `com.kuhy.focus_owner` is absent from `allowed_packages`, +absent from `night_allowed_packages`, and matches none of the 44 +`never_disable_prefixes`. The decision layer therefore places the app in +`packagesToHide` on **every** enforcing pass, and the `pkg == +context.packageName` check at line 81 is the _only_ thing stopping the app from +hiding itself — which would remove the escape hatch and the "Run enforcement +now" trigger together. + +Fix belongs in the exporter (`python_pkg/focus_policy/export.py`), not a +hand-edit of the asset, so the guarantee is generated rather than remembered. +The line-81 check should stay either way; only the comment is wrong. + +## Wiring gap found + +`EnforcementRunner.decide()` hardcodes `workoutActive = false` +(`EnforcementRunner.kt:57`). The decision layer honours the flag +(`EnforcementDecision.kt:83`), but nothing feeds it in the Kotlin path — and +the Kotlin path is the one that runs at alarm time, since a background service +has no Flutter engine. **The workout unblock is currently dead in the deployed +path.** Not fixed in this session. + +The Dart side is thinner than it looks. `lib/workout_signal.dart` defines +`ManualWorkoutSignal`, `GuardedWorkoutSignal` and `CombinedWorkoutSignal`, all +unit-tested, but none is constructed anywhere in `lib/` — and there is **no +Health Connect integration at all**: no `androidx.health` dependency, no +`android.permission.health.*` in the manifest, no query. `'health_connect'` +appears only as a fixture string in `test/workout_signal_test.dart`. Wiring +Health Connect is a from-scratch build, not a port. + +It is, however, a viable one. Measured on device: `com.stronglifts.app` holds +`android.permission.health.WRITE_EXERCISE`, `WRITE_ACTIVE_CALORIES_BURNED`, +`WRITE_WEIGHT`, `WRITE_HEIGHT` and the matching `READ_*` grants, all +`granted=true`, and `com.google.android.apps.healthdata` is installed. So the +workout data the rooted system used to read out of StrongLifts' SQLite file is +reachable through a supported API, which is the natural replacement for that +lost signal. + +## Home coordinates: placeholder found and replaced + +`config_secrets.sh` still held `REDACTED_LAT`/`REDACTED_LON`, so the policy +asset could not be regenerated. Real coordinates were taken from the Pixel 6a's +own fix rather than a map lookup — `52.228948, 20.951184`, network provider, +`hAcc=21.7 m`, consistent across the fused and network providers. + +That surfaced a second staleness problem: the coordinates already provisioned +into the app's private storage were `52.2297, 21.0122` — Warsaw city centre, a +placeholder. **4156 m from the real location, against a 150 m radius**, so the +geofence would have read as permanently away from home. Re-provisioned via +`scripts/push_home_location.sh` and verified on device. + +Regenerating `assets/policy.json` then confirmed the exporter fix end-to-end: +`com.kuhy.focus_owner` now appears in both allowlists. The regeneration also +picked up `com.kuhy.untools`, which was already in `config.sh:365` — the +committed asset was simply stale. + +The committed asset stays redacted: `latitude: null`, `longitude: null`, and +zero occurrences of the real values. The unredacted export (stdout only, used +by the provisioning script) carries them. + +## Teardown (done) + +`pm remove-user 12` → `Success: removed user`; a clean build (committed policy +asset, no widened allowlist) reinstalled into user 0; staged +`/data/local/tmp/home_location.json` removed. Verified afterwards: + +- `pm list users` → user 0 only +- `Device Owner Type: -1`, no profile owner +- `com.termux`, `org.breezyweather`, `de.duenndns.gmdice`, + `com.stronglifts.app` all `hidden=false` in user 0 + +Three `EnforcementAlarmReceiver` alarms remain registered by the user-0 +instance. They are inert: `DevicePolicyBridge.setApplicationHidden` returns +early when the caller is neither device owner nor profile owner, which user 0 +is not. diff --git a/python_pkg/focus_policy/export.py b/python_pkg/focus_policy/export.py index 3733a374..681f7173 100644 --- a/python_pkg/focus_policy/export.py +++ b/python_pkg/focus_policy/export.py @@ -19,6 +19,14 @@ if TYPE_CHECKING: 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" + def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]: """Return a JSON-serialisable representation of ``policy``.""" @@ -39,8 +47,10 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]: }, "curfew": curfew, "launcher_package": policy.launcher_package, - "allowed_packages": sorted(policy.allowed_packages), - "night_allowed_packages": sorted(policy.night_allowed_packages), + "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), diff --git a/python_pkg/focus_policy/tests/test_export.py b/python_pkg/focus_policy/tests/test_export.py new file mode 100644 index 00000000..9542ac49 --- /dev/null +++ b/python_pkg/focus_policy/tests/test_export.py @@ -0,0 +1,109 @@ +"""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, +) -> 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, + night_allowed_packages=night_allowed_packages, + never_disable_prefixes=("com.android.",), + 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_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()) diff --git a/python_pkg/focus_policy/tests/test_loader.py b/python_pkg/focus_policy/tests/test_loader.py index 28cd03f7..a36b3a33 100644 --- a/python_pkg/focus_policy/tests/test_loader.py +++ b/python_pkg/focus_policy/tests/test_loader.py @@ -218,7 +218,13 @@ class TestExport: payload = policy_to_dict(load_policy(config_dir / "config.sh")) assert payload["schema_version"] == 1 assert payload["curfew"] == {"start": "23:00", "end": "05:00"} - assert payload["allowed_packages"] == ["com.good", "com.launcher", "pl.mbank"] + # The exporter injects the enforcer itself; see ENFORCER_PACKAGE. + assert payload["allowed_packages"] == [ + "com.good", + "com.kuhy.focus_owner", + "com.launcher", + "pl.mbank", + ] assert payload["home"]["latitude"] == pytest.approx(52.2297) def test_absent_curfew_serialises_as_null(self, config_dir: Path) -> None: