From 1b7958143d4224c9bcd8632cc097b05fc9f1e472 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 21:36:26 +0200 Subject: [PATCH] Prove DPM app-hiding survives a reboot; build the enforcement decision layer The whole Device Owner design assumed setApplicationHidden persists across a reboot, and that had never been tested -- while both shell primitives were measured to be cleared by one. Settling it looked like it needed a factory reset. It does not: a managed profile is removable and takes its profile-owner grant with it, so the DPM API can be exercised and then erased. pm create-user --profileOf 0 --managed -> user 10 dpm set-profile-owner --user 10 ... ... hide, reboot, re-check ... pm remove-user 10 hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome in user 0 untouched. Scope limit, stated because it is easy to overclaim: this proves the API's hidden state persists under PROFILE OWNER inside a work profile. It does not prove a DEVICE OWNER can hide a given app in user 0, which is what the design actually needs -- DO there additionally cannot touch the launcher, the default dialer/SMS/IME or packages the system deems critical, and the call can return false silently. That must be tested in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit. On top of that, EnforcementDecision turns policy plus conditions into a hide/show set. It is a pure function, so the whole decision layer is tested without a device, a clock or a location provider. packagesToShow is deliberately the complete allowed set rather than a delta. Failing to hide something is a mild disappointment; failing to UNHIDE strands the user without a dialer or a bank at 09:00 because the phone rebooted during curfew. Applying the full set on every evaluation makes a missed run self-repairing, which matters because BOOT_COMPLETED does not fire until first unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after a reboot with no network at all. The workout exception is expressed as packages rather than domains. The rooted system unblocked youtube.com in a hosts file; hiding an app cannot target a domain, so the package set is derived from the configured domains and config.sh stays the source of truth for intent. Signal sources were checked on the device before being built: StrongLifts is granted health.WRITE_EXERCISE and Health Connect is installed, so that path is viable; com.kuhy.workout_app exposes only a MainActivity and Flutter boilerplate; org.runnerup is not installed here at all. Only the two that can actually produce a signal will be wired. HideTestReceiver is temporary and must be deleted before any provisioned build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL --- ...s-owner-enforcement-engine-2026-08-09.json | 24 ++ ...s-owner-enforcement-engine-2026-08-09.json | 51 ++++ .../android/app/src/main/AndroidManifest.xml | 12 + .../kuhy/focus_owner/DevicePolicyBridge.kt | 36 +++ .../com/kuhy/focus_owner/HideTestReceiver.kt | 58 ++++ .../com/kuhy/focus_owner/MainActivity.kt | 17 ++ focus_owner/lib/enforcement.dart | 192 ++++++++++++ focus_owner/lib/policy.dart | 27 ++ focus_owner/test/enforcement_test.dart | 276 ++++++++++++++++++ 9 files changed, 693 insertions(+) create mode 100644 docs/superpowers/contracts/focus-owner-enforcement-engine-2026-08-09.json create mode 100644 docs/superpowers/evidence/focus-owner-enforcement-engine-2026-08-09.json create mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HideTestReceiver.kt create mode 100644 focus_owner/lib/enforcement.dart create mode 100644 focus_owner/test/enforcement_test.dart 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, + ); + }); + }); +}