mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
This commit is contained in:
parent
58c1df573c
commit
f33fd03a16
@ -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"
|
||||
}
|
||||
@ -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 <pkg> (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 <changed-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 <pkg> or by removing the profile."
|
||||
]
|
||||
}
|
||||
@ -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<String> {
|
||||
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()
|
||||
|
||||
@ -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",
|
||||
|
||||
172
focus_owner/docs/profile-owner-verification-2026-08-09.md
Normal file
172
focus_owner/docs/profile-owner-verification-2026-08-09.md
Normal file
@ -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 <pkg>` 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.
|
||||
@ -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),
|
||||
|
||||
109
python_pkg/focus_policy/tests/test_export.py
Normal file
109
python_pkg/focus_policy/tests/test_export.py
Normal file
@ -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())
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user