diff --git a/docs/superpowers/contracts/focus-owner-enforcement-engine-2026-08-09.json b/docs/superpowers/contracts/focus-owner-enforcement-engine-2026-08-09.json
new file mode 100644
index 00000000..ead78fb6
--- /dev/null
+++ b/docs/superpowers/contracts/focus-owner-enforcement-engine-2026-08-09.json
@@ -0,0 +1,24 @@
+{
+ "title": "Prove DPM app-hiding persists, and build the enforcement decision layer on it",
+ "objective": "The Device Owner design rests on setApplicationHidden surviving a reboot, which had never been tested - both shell primitives were measured to be cleared by one. Prove or disprove that WITHOUT spending a factory reset, then build the decision layer that turns the parsed policy plus current conditions into a hide/show set. Success is a measured persistence result with its scope stated honestly, and a pure, fully tested decision function.",
+ "acceptance_criteria": [
+ "setApplicationHidden persistence measured across a real reboot, using a removable managed profile so no wipe is spent",
+ "The test profile and its profile-owner grant are removed afterwards, with user 0 verified untouched",
+ "The result is recorded with its scope limit: profile owner in a work profile is NOT device owner in user 0",
+ "EnforcementDecision is a pure function of its inputs - testable with no device, clock or location provider",
+ "packagesToShow is always the complete allowed set rather than a delta, so a missed run self-repairs",
+ "Unknown location enforces rather than releasing, matching focus_daemon.sh",
+ "A policy with redacted coordinates still enforces rather than reading as always-away",
+ "Geofence hysteresis prevents flapping between radius and radius+hysteresis",
+ "The workout exception releases the YouTube packages and nothing else",
+ "Workout signal sources are verified on-device before being built, not assumed"
+ ],
+ "out_of_scope": [
+ "Device Owner provisioning and the factory reset - unchanged, still not done",
+ "The foreground service, BOOT_COMPLETED receiver and alarm scheduling - next step",
+ "DNS filtering, which remains parked",
+ "com.kuhy.workout_app as a signal source: verified to expose nothing readable",
+ "org.runnerup: not installed on this device"
+ ],
+ "verifier": "pm create-user/dpm set-profile-owner + adb reboot + dumpsys package, then pm remove-user and re-check user 0; cd focus_owner && flutter analyze && flutter test"
+}
diff --git a/docs/superpowers/evidence/focus-owner-enforcement-engine-2026-08-09.json b/docs/superpowers/evidence/focus-owner-enforcement-engine-2026-08-09.json
new file mode 100644
index 00000000..452a8e99
--- /dev/null
+++ b/docs/superpowers/evidence/focus-owner-enforcement-engine-2026-08-09.json
@@ -0,0 +1,51 @@
+{
+ "intent": "Settle whether DPM app-hiding survives a reboot without spending a factory reset on the question, then build the enforcement decision layer on the answer.",
+ "scope": [
+ "focus_owner/lib/enforcement.dart - new: pure hide/show decision function",
+ "focus_owner/lib/policy.dart - workoutExemptPackages, mapping unblock domains to packages",
+ "focus_owner/android/.../DevicePolicyBridge.kt - setApplicationHidden / isApplicationHidden / isProfileOwner",
+ "focus_owner/android/.../HideTestReceiver.kt - new, TEMPORARY: adb-driveable test hook",
+ "focus_owner/test/enforcement_test.dart - new: 16 tests",
+ "Non-goal: no Device Owner provisioning, no factory reset",
+ "Non-goal: the foreground service, boot receiver and alarms are the next step"
+ ],
+ "changes": [
+ "Measured setApplicationHidden persistence using a removable managed profile (pm create-user --profileOf 0 --managed, dpm set-profile-owner) rather than a factory reset. hidden=true before reboot, hidden=true after, confirmed by both dumpsys and the DPM API. The profile was then removed, taking the profile-owner grant and all hidden state with it.",
+ "EnforcementDecision.evaluate is a pure function of policy plus conditions, so the whole decision layer is testable with no device, clock or location provider.",
+ "packagesToShow is always the complete allowed set rather than a delta. Unhiding is the failure mode that matters: failing to hide is a mild disappointment, failing to unhide strands the user without a dialer or a bank after a reboot during curfew. Applying the full set every run makes a missed evaluation self-repairing.",
+ "The workout exception is expressed as packages, not domains. The rooted system unblocked youtube.com in a hosts file; hiding apps cannot target a domain, so workoutExemptPackages derives the package set from the configured domains and keeps config.sh as the source of truth for intent."
+ ],
+ "verification": [
+ {
+ "command": "pm create-user --profileOf 0 --managed; dpm set-profile-owner; am broadcast HIDE; adb reboot; dumpsys package",
+ "result": "pass",
+ "evidence": "hidden=true before reboot and hidden=true after, corroborated by the DPM API's own isApplicationHidden ('RESULT query com.android.chrome -> hidden=true'). This is the assumption the entire Device Owner design rests on, and it holds."
+ },
+ {
+ "command": "pm remove-user 10; dumpsys device_policy; dumpsys package com.android.chrome",
+ "result": "pass",
+ "evidence": "'Success: removed user'. Device Owner Type: -1, effective restrictions none, and Chrome in user 0 shows hidden=false - the real user was never touched."
+ },
+ {
+ "command": "cd focus_owner && flutter analyze && flutter test",
+ "result": "pass",
+ "evidence": "'No issues found!' and 43 tests passing, 16 of them new: away/at-home/curfew transitions, midnight wrap, hysteresis at 160m in both directions, fail-closed on unknown location, fail-closed on redacted coordinates, the workout exception, and an invariant that hide and show partition the installed set."
+ },
+ {
+ "command": "adb shell pm list packages / dumpsys package (workout signal sources)",
+ "result": "pass",
+ "evidence": "StrongLifts declares AND is granted android.permission.health.WRITE_EXERCISE, and com.google.android.apps.healthdata is installed, so the Health Connect path is viable. com.kuhy.workout_app exposes only MainActivity plus Flutter boilerplate - no provider or broadcast to read. org.runnerup is not installed at all. Two of the three proposed sources were checked before being built rather than after."
+ }
+ ],
+ "risks": [
+ "SCOPE LIMIT, stated deliberately: persistence was proven for PROFILE OWNER inside a work profile. That is NOT the same as DEVICE OWNER hiding apps in user 0, which is what the real design needs. DO in user 0 additionally cannot hide the current launcher, the default dialer/SMS/IME, or packages the system deems critical, and setApplicationHidden may return false silently rather than throwing. Test this in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit.",
+ "HideTestReceiver is exported=true. It is gated behind MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app can hold, but it is a receiver that applies policy and it must be deleted before any provisioned build. Tracked.",
+ "BOOT_COMPLETED does not fire until first unlock. Observed directly: the phone sat at RUNNING_LOCKED for several minutes after a reboot with no network and no app start. Enforcement state therefore persists unchanged across a reboot until the user unlocks - fail-closed for hiding, but also fail-closed for unhiding. The service layer must make boundary transitions alarm-driven and idempotent.",
+ "workoutExemptPackages hardcodes the youtube.com to package mapping. Adding a new unblock domain to config.sh will not automatically map to a package."
+ ],
+ "rollback": [
+ "git revert. enforcement.dart is new and nothing outside focus_owner imports it; the app applies no policy in this build.",
+ "Nothing on the phone needs undoing - the test profile is already removed and user 0 was verified unchanged.",
+ "After rollback validate: flutter analyze && flutter test still pass, and dumpsys device_policy still reports Device Owner Type: -1."
+ ]
+}
diff --git a/focus_owner/android/app/src/main/AndroidManifest.xml b/focus_owner/android/app/src/main/AndroidManifest.xml
index 28459d3a..598742f7 100644
--- a/focus_owner/android/app/src/main/AndroidManifest.xml
+++ b/focus_owner/android/app/src/main/AndroidManifest.xml
@@ -41,6 +41,18 @@
+
+
+
+
+
+
+
+
+ Log.e(FocusDeviceAdminReceiver.TAG, "setApplicationHidden failed", error)
+ false
+ }
+ }
+
+ /** Whether a package is currently hidden by this admin. */
+ fun isApplicationHidden(packageName: String): Boolean {
+ if (!isDeviceOwner() && !isProfileOwner()) return false
+ return runCatching { dpm.isApplicationHidden(admin, packageName) }
+ .getOrDefault(false)
+ }
+
/** Whether the admin component is active (a weaker state than ownership). */
fun isAdminActive(): Boolean = dpm.isAdminActive(admin)
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HideTestReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HideTestReceiver.kt
new file mode 100644
index 00000000..6f74f13a
--- /dev/null
+++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HideTestReceiver.kt
@@ -0,0 +1,58 @@
+package com.kuhy.focus_owner
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+/**
+ * ADB-driveable hook for exercising [DevicePolicyBridge.setApplicationHidden].
+ *
+ * It exists to answer one question that a factory reset should not be spent
+ * guessing at: does DPM app-hiding survive a reboot? Both `pm suspend` and
+ * `pm disable-user` were measured to be cleared by one on this device, and the
+ * Device Owner design assumes `setApplicationHidden` behaves differently.
+ *
+ * Driving that from a UI would mean tapping through a screen before and after
+ * every reboot; a broadcast can be scripted, so the test is repeatable.
+ *
+ * adb shell am broadcast -a com.kuhy.focus_owner.HIDE \
+ * --es package --ez hidden true --user
+ *
+ * It is exported so `am broadcast` can reach it at all — a non-exported
+ * receiver silently drops shell broadcasts — but gated behind
+ * MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app
+ * can hold. In practice that means the shell and the system, and nothing else.
+ *
+ * It applies policy that this build otherwise never applies, which is why it is
+ * confined to a test affordance rather than wired into normal startup. Remove
+ * it once app-hiding persistence is settled.
+ */
+class HideTestReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val bridge = DevicePolicyBridge(context.applicationContext)
+ val pkg = intent.getStringExtra("package")
+ if (pkg == null) {
+ Log.w(TAG, "HIDE broadcast with no package extra")
+ return
+ }
+ when (intent.action) {
+ ACTION_HIDE -> {
+ val hidden = intent.getBooleanExtra("hidden", true)
+ val applied = bridge.setApplicationHidden(pkg, hidden)
+ Log.i(TAG, "RESULT hide $pkg -> requested=$hidden applied=$applied")
+ }
+ ACTION_QUERY -> {
+ Log.i(TAG, "RESULT query $pkg -> hidden=${bridge.isApplicationHidden(pkg)}")
+ }
+ else -> Log.w(TAG, "unexpected action ${intent.action}")
+ }
+ }
+
+ companion object {
+ private const val TAG = FocusDeviceAdminReceiver.TAG
+ const val ACTION_HIDE = "com.kuhy.focus_owner.HIDE"
+ const val ACTION_QUERY = "com.kuhy.focus_owner.QUERY"
+ }
+}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt
index ffd12ad5..354af21c 100644
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt
+++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt
@@ -14,6 +14,23 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"status" -> result.success(bridge.status())
"releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
+ "setApplicationHidden" -> {
+ val pkg = call.argument("package")
+ val hidden = call.argument("hidden")
+ if (pkg == null || hidden == null) {
+ result.error("bad_args", "package and hidden required", null)
+ } else {
+ result.success(bridge.setApplicationHidden(pkg, hidden))
+ }
+ }
+ "isApplicationHidden" -> {
+ val pkg = call.argument("package")
+ if (pkg == null) {
+ result.error("bad_args", "package required", null)
+ } else {
+ result.success(bridge.isApplicationHidden(pkg))
+ }
+ }
else -> result.notImplemented()
}
}
diff --git a/focus_owner/lib/enforcement.dart b/focus_owner/lib/enforcement.dart
new file mode 100644
index 00000000..0b1d0f42
--- /dev/null
+++ b/focus_owner/lib/enforcement.dart
@@ -0,0 +1,192 @@
+import 'dart:math' as math;
+
+import 'package:focus_owner/policy.dart';
+
+/// Why enforcement is currently on or off.
+///
+/// Carried alongside the decision so the UI and the logs can say *why* an app
+/// is hidden. "Blocked" with no reason is the kind of thing that makes a
+/// wellbeing tool feel broken rather than deliberate.
+enum EnforcementReason {
+ /// Away from home, so nothing is enforced.
+ away,
+
+ /// At home during the day: the full allowlist applies.
+ atHome,
+
+ /// Inside the curfew window: the much shorter night allowlist applies.
+ curfew,
+
+ /// A workout is in progress, so the workout exceptions are released.
+ workout,
+
+ /// Location is unknown. Treated as "at home" deliberately — see
+ /// [EnforcementDecision.evaluate].
+ locationUnknown,
+}
+
+/// Inputs to one enforcement decision.
+///
+/// Grouped into a value type so the decision is a pure function of its inputs
+/// and can be tested without a device, a clock, or a location provider.
+class EnforcementInputs {
+ const EnforcementInputs({
+ required this.installedPackages,
+ required this.minutesSinceMidnight,
+ this.latitude,
+ this.longitude,
+ this.currentlyEnforcing = false,
+ this.workoutActive = false,
+ });
+
+ /// Third-party packages present on the device.
+ final Set installedPackages;
+
+ /// Local time, as minutes since midnight.
+ final int minutesSinceMidnight;
+
+ /// Current position, or null when location is unavailable.
+ final double? latitude;
+ final double? longitude;
+
+ /// Whether enforcement is already active, for geofence hysteresis.
+ final bool currentlyEnforcing;
+
+ /// Whether a workout is in progress.
+ final bool workoutActive;
+}
+
+/// The outcome of evaluating the policy against the current conditions.
+class EnforcementDecision {
+ const EnforcementDecision({
+ required this.reason,
+ required this.packagesToHide,
+ required this.packagesToShow,
+ });
+
+ /// Decides which packages should be hidden right now.
+ ///
+ /// Fail-closed on unknown location, matching `focus_daemon.sh`, which logs
+ /// "Location unavailable - defaulting to focus mode". Losing GPS must not be
+ /// a way to switch enforcement off.
+ ///
+ /// Note the asymmetry that makes this safe to run unattended: every package
+ /// the policy allows appears in [packagesToShow] on *every* evaluation, not
+ /// only on transitions. A missed run therefore leaves apps hidden that
+ /// should be visible only until the next run, and the next run repairs it
+ /// without needing to know what the previous one did.
+ factory EnforcementDecision.evaluate(
+ FocusPolicy policy,
+ EnforcementInputs inputs,
+ ) {
+ final hasFix = inputs.latitude != null && inputs.longitude != null;
+ final atHome = hasFix &&
+ _isInside(
+ policy,
+ inputs.latitude!,
+ inputs.longitude!,
+ currentlyEnforcing: inputs.currentlyEnforcing,
+ );
+
+ if (hasFix && !atHome) {
+ return EnforcementDecision(
+ reason: EnforcementReason.away,
+ packagesToHide: const {},
+ packagesToShow: inputs.installedPackages,
+ );
+ }
+
+ final duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight);
+ final reason = !hasFix
+ ? EnforcementReason.locationUnknown
+ : duringCurfew
+ ? EnforcementReason.curfew
+ : EnforcementReason.atHome;
+
+ final hide = {};
+ final show = {};
+ for (final package in inputs.installedPackages) {
+ if (policy.isAllowed(package, duringCurfew: duringCurfew)) {
+ show.add(package);
+ } else {
+ hide.add(package);
+ }
+ }
+
+ if (inputs.workoutActive) {
+ // A workout releases its exception apps from whatever the current
+ // state would otherwise be, without disturbing anything else.
+ for (final package in policy.workoutExemptPackages) {
+ if (hide.remove(package)) show.add(package);
+ }
+ return EnforcementDecision(
+ reason: EnforcementReason.workout,
+ packagesToHide: hide,
+ packagesToShow: show,
+ );
+ }
+
+ return EnforcementDecision(
+ reason: reason,
+ packagesToHide: hide,
+ packagesToShow: show,
+ );
+ }
+
+ /// Why this decision was reached.
+ final EnforcementReason reason;
+
+ /// Packages that must be hidden.
+ final Set packagesToHide;
+
+ /// Packages that must be visible.
+ ///
+ /// Always the complete allowed set, never a delta. Unhiding is the failure
+ /// mode that matters: a system that fails to hide is a mild disappointment,
+ /// one that fails to unhide strands the user without a dialer or a bank.
+ final Set packagesToShow;
+
+ /// Whether anything is currently restricted.
+ bool get isEnforcing => packagesToHide.isNotEmpty;
+
+ static bool _isInside(
+ FocusPolicy policy,
+ double latitude,
+ double longitude, {
+ required bool currentlyEnforcing,
+ }) {
+ final home = policy.home;
+ if (!home.hasCoordinates) {
+ // No coordinates configured: the geofence cannot be evaluated, so it
+ // must not silently pass. Treated as "at home" by the caller via the
+ // hasFix path above; here we simply refuse to claim the user is away.
+ return true;
+ }
+ final metres = _haversineMetres(
+ home.latitude!,
+ home.longitude!,
+ latitude,
+ longitude,
+ );
+ final threshold = home.radiusM + (currentlyEnforcing ? home.hysteresisM : 0);
+ return metres <= threshold;
+ }
+
+ static double _haversineMetres(
+ double lat1,
+ double lon1,
+ double lat2,
+ double lon2,
+ ) {
+ const earthRadiusM = 6371000.0;
+ final dLat = _radians(lat2 - lat1);
+ final dLon = _radians(lon2 - lon1);
+ final a = math.pow(math.sin(dLat / 2), 2) +
+ math.cos(_radians(lat1)) *
+ math.cos(_radians(lat2)) *
+ math.pow(math.sin(dLon / 2), 2);
+ return 2 * earthRadiusM * math.asin(math.sqrt(a.toDouble()));
+ }
+
+ static double _radians(double degrees) => degrees * math.pi / 180.0;
+}
diff --git a/focus_owner/lib/policy.dart b/focus_owner/lib/policy.dart
index 29e60e53..b782ff95 100644
--- a/focus_owner/lib/policy.dart
+++ b/focus_owner/lib/policy.dart
@@ -168,6 +168,33 @@ class FocusPolicy {
/// Whether the curfew is in force at [minutesSinceMidnight].
bool isCurfewActive(int minutesSinceMidnight) =>
curfew?.contains(minutesSinceMidnight) ?? false;
+
+ /// Packages released while a workout is in progress.
+ ///
+ /// The rooted system expressed this exception as *domains*, because it
+ /// enforced through a hosts file. Hiding apps is a coarser instrument: there
+ /// is no way to unblock youtube.com without unhiding the YouTube app, so the
+ /// exception is expressed as packages here.
+ ///
+ /// Derived from [workoutUnblockDomains] rather than configured separately,
+ /// so that the shell config stays the single source of truth for *intent*
+ /// even though the mechanism differs.
+ Set get workoutExemptPackages {
+ const domainToPackages = >{
+ 'youtube.com': [
+ 'com.google.android.youtube',
+ 'com.google.android.apps.youtube.music',
+ ],
+ };
+ final packages = {};
+ for (final entry in domainToPackages.entries) {
+ final covered = workoutUnblockDomains.any(
+ (domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
+ );
+ if (covered) packages.addAll(entry.value);
+ }
+ return packages;
+ }
}
Set _stringSet(Object? value, String field) {
diff --git a/focus_owner/test/enforcement_test.dart b/focus_owner/test/enforcement_test.dart
new file mode 100644
index 00000000..43065ba0
--- /dev/null
+++ b/focus_owner/test/enforcement_test.dart
@@ -0,0 +1,276 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:focus_owner/enforcement.dart';
+import 'package:focus_owner/policy.dart';
+
+/// Warsaw, used only as a fixed reference point for the geofence maths.
+const double _homeLat = 52.2297;
+const double _homeLon = 21.0122;
+const double _metresPerDegreeLat = 111320;
+
+FocusPolicy _policy({bool withCoordinates = true, Object? curfew}) =>
+ FocusPolicy.fromJson({
+ 'schema_version': kSupportedSchemaVersion,
+ 'home': {
+ 'latitude': withCoordinates ? _homeLat : null,
+ 'longitude': withCoordinates ? _homeLon : null,
+ 'radius_m': 150.0,
+ 'hysteresis_m': 30.0,
+ },
+ 'curfew': curfew ?? const {'start': '23:00', 'end': '05:00'},
+ 'launcher_package': 'com.launcher',
+ 'allowed_packages': [
+ 'com.launcher',
+ 'pl.mbank',
+ 'com.discord',
+ 'com.google.android.youtube',
+ ],
+ 'night_allowed_packages': ['com.launcher', 'pl.mbank'],
+ 'never_disable_prefixes': ['com.android.settings'],
+ 'workout_unblock_domains': ['youtube.com', 'googlevideo.com'],
+ 'browser_packages': [],
+ });
+
+const _installed = {
+ 'com.launcher',
+ 'pl.mbank',
+ 'com.discord',
+ 'com.google.android.youtube',
+ 'com.evil',
+ 'com.android.settings',
+};
+
+/// A point [metres] north of home.
+double _latOffset(double metres) => _homeLat + metres / _metresPerDegreeLat;
+
+void main() {
+ group('away from home', () {
+ test('nothing is hidden and everything is shown', () {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _latOffset(5000),
+ longitude: _homeLon,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.away);
+ expect(decision.packagesToHide, isEmpty);
+ expect(decision.packagesToShow, _installed);
+ expect(decision.isEnforcing, isFalse);
+ });
+ });
+
+ group('at home during the day', () {
+ late EnforcementDecision decision;
+
+ setUp(() {
+ decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ ),
+ );
+ });
+
+ test('only non-allowlisted apps are hidden', () {
+ expect(decision.reason, EnforcementReason.atHome);
+ expect(decision.packagesToHide, {'com.evil'});
+ });
+
+ test('protected system packages are never hidden', () {
+ expect(decision.packagesToShow, contains('com.android.settings'));
+ });
+
+ test('the launcher is never hidden', () {
+ expect(decision.packagesToShow, contains('com.launcher'));
+ });
+ });
+
+ group('curfew', () {
+ test('the night list is much shorter than the day list', () {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 23 * 60 + 30,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.curfew);
+ expect(decision.packagesToHide, containsAll(['com.discord', 'com.evil']));
+ expect(decision.packagesToShow, containsAll(['pl.mbank', 'com.launcher']));
+ });
+
+ test('curfew wraps past midnight', () {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 3 * 60,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.curfew);
+ });
+
+ test('after the curfew ends the day list applies again', () {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 6 * 60,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.atHome);
+ expect(decision.packagesToShow, contains('com.discord'));
+ });
+ });
+
+ group('geofence hysteresis', () {
+ // 160m is outside the 150m radius but inside radius+hysteresis (180m), so
+ // the answer must depend on the current state or the enforcer flaps.
+ EnforcementDecision at160m({required bool enforcing}) =>
+ EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _latOffset(160),
+ longitude: _homeLon,
+ currentlyEnforcing: enforcing,
+ ),
+ );
+
+ test('arriving: not yet inside', () {
+ expect(at160m(enforcing: false).reason, EnforcementReason.away);
+ });
+
+ test('leaving: still inside', () {
+ expect(at160m(enforcing: true).reason, EnforcementReason.atHome);
+ });
+
+ test('well beyond the band is away in both states', () {
+ for (final enforcing in [true, false]) {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _latOffset(500),
+ longitude: _homeLon,
+ currentlyEnforcing: enforcing,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.away);
+ }
+ });
+ });
+
+ group('fail-closed behaviour', () {
+ test('unknown location enforces rather than releasing', () {
+ // Losing GPS must not be a way to switch enforcement off. Matches
+ // focus_daemon.sh, which defaults to focus mode when location fails.
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.locationUnknown);
+ expect(decision.packagesToHide, contains('com.evil'));
+ });
+
+ test('a policy with redacted coordinates still enforces', () {
+ // The committed policy.json has no coordinates. That must not silently
+ // disable the geofence into "always away".
+ final decision = EnforcementDecision.evaluate(
+ _policy(withCoordinates: false),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _latOffset(5000),
+ longitude: _homeLon,
+ ),
+ );
+ expect(decision.packagesToHide, contains('com.evil'));
+ });
+ });
+
+ group('workout exception', () {
+ test('releases YouTube without releasing anything else', () {
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 23 * 60 + 30,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ workoutActive: true,
+ ),
+ );
+ expect(decision.reason, EnforcementReason.workout);
+ expect(decision.packagesToShow, contains('com.google.android.youtube'));
+ expect(decision.packagesToHide, contains('com.evil'));
+ expect(decision.packagesToHide, contains('com.discord'));
+ });
+
+ test('domains map to packages, since hiding cannot target a domain', () {
+ expect(
+ _policy().workoutExemptPackages,
+ contains('com.google.android.youtube'),
+ );
+ });
+
+ test('no workout domains means no exempt packages', () {
+ final policy = FocusPolicy.fromJson({
+ 'schema_version': kSupportedSchemaVersion,
+ 'home': {
+ 'latitude': null,
+ 'longitude': null,
+ 'radius_m': 150.0,
+ 'hysteresis_m': 30.0,
+ },
+ 'curfew': null,
+ 'launcher_package': null,
+ 'allowed_packages': [],
+ 'night_allowed_packages': [],
+ 'never_disable_prefixes': [],
+ 'workout_unblock_domains': [],
+ 'browser_packages': [],
+ });
+ expect(policy.workoutExemptPackages, isEmpty);
+ });
+ });
+
+ group('unhide reliability', () {
+ test('packagesToShow is the full allowed set, not a delta', () {
+ // The service applies packagesToShow unconditionally on every run, so a
+ // missed evaluation self-repairs. A delta would require knowing what the
+ // previous run did, and a reboot loses that.
+ final decision = EnforcementDecision.evaluate(
+ _policy(),
+ EnforcementInputs(
+ installedPackages: _installed,
+ minutesSinceMidnight: 12 * 60,
+ latitude: _homeLat,
+ longitude: _homeLon,
+ ),
+ );
+ final union = {...decision.packagesToHide, ...decision.packagesToShow};
+ expect(union, _installed);
+ expect(
+ decision.packagesToHide.intersection(decision.packagesToShow),
+ isEmpty,
+ );
+ });
+ });
+}