From cc804dd31102cd0e3de7e6f85dbaa0c7480ebdfe Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 21:50:58 +0200 Subject: [PATCH] Run enforcement from exact alarms, and re-arm it before first unlock The decision layer needed a runtime. A polling loop was the obvious design and the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00 boundary leaves the curfew allowlist applied into the morning -- the user finds their banking app missing with no obvious cause. The transitions are known ahead of time, so they get exact alarms. The service wakes, applies, schedules the next run and stops rather than sitting resident. BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the latter does not arrive until first unlock. That is not theoretical: this phone sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with no network and no app able to start. Relying on BOOT_COMPLETED alone freezes enforcement state across a reboot -- safe for hiding, not for unhiding, which is the direction that strands you without a dialer. The schedule is re-armed before unlock; the pass itself waits until storage is readable. The first on-device run logged "exact alarms not permitted", and dumpsys showed what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and denied by default on Android 16; USE_EXACT_ALARM is the permission for apps whose function is acting at specific times, and is granted at install. After the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to maxWhenElapsed. The service reports "not DO/PO - no enforcement applied" and does nothing when unprovisioned, rather than failing quietly, and a failure inside a pass cannot stop the next run being scheduled -- an enforcement bug should degrade one tick, not end the chain. Not yet wired: the service logs a tick rather than calling setApplicationHidden with the decision. Both halves are tested; joining them is next. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL --- .../focus-owner-service-layer-2026-08-09.json | 19 ++++ .../focus-owner-service-layer-2026-08-09.json | 52 ++++++++++ .../android/app/src/main/AndroidManifest.xml | 46 +++++++++ .../com/kuhy/focus_owner/BootReceiver.kt | 45 +++++++++ .../focus_owner/EnforcementAlarmReceiver.kt | 20 ++++ .../kuhy/focus_owner/EnforcementScheduler.kt | 78 +++++++++++++++ .../kuhy/focus_owner/EnforcementService.kt | 95 +++++++++++++++++++ .../com/kuhy/focus_owner/MainActivity.kt | 11 +++ focus_owner/lib/device_policy.dart | 12 +++ focus_owner/lib/main.dart | 24 +++++ 10 files changed, 402 insertions(+) create mode 100644 docs/superpowers/contracts/focus-owner-service-layer-2026-08-09.json create mode 100644 docs/superpowers/evidence/focus-owner-service-layer-2026-08-09.json create mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt create mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt create mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt create mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt diff --git a/docs/superpowers/contracts/focus-owner-service-layer-2026-08-09.json b/docs/superpowers/contracts/focus-owner-service-layer-2026-08-09.json new file mode 100644 index 00000000..ba75558d --- /dev/null +++ b/docs/superpowers/contracts/focus-owner-service-layer-2026-08-09.json @@ -0,0 +1,19 @@ +{ + "title": "Alarm-driven enforcement service that survives reboot and Doze", + "objective": "Give the enforcement decision a runtime: something that applies it at the moments it can change, survives a reboot, and is not silently deferred by Doze. Success is a service that runs, arms its own next run, fires exactly at scheduled times, and refuses to act when not provisioned rather than failing quietly.", + "acceptance_criteria": [ + "The service applies one pass then stops; it is not resident and does not poll", + "Curfew boundaries fire exactly - window=0 in dumpsys alarm, not a multi-minute window", + "A reboot re-arms the schedule even before the user unlocks", + "The service reports and does nothing when neither device owner nor profile owner, rather than failing silently", + "A failure inside a pass cannot stop the next run being scheduled", + "packagesToShow is applied unconditionally each pass so a missed run self-repairs" + ], + "out_of_scope": [ + "Device Owner provisioning - the service correctly no-ops without it", + "The actual hide/show application: the decision layer and the DPM call both exist, wiring them together is next", + "Location and workout providers - interfaces exist, platform plumbing does not", + "DNS filtering, still parked" + ], + "verifier": "adb install + am start; adb logcat for the boot and service lines; dumpsys alarm to confirm window=0 and exactAllowReason; flutter analyze && flutter test" +} diff --git a/docs/superpowers/evidence/focus-owner-service-layer-2026-08-09.json b/docs/superpowers/evidence/focus-owner-service-layer-2026-08-09.json new file mode 100644 index 00000000..90cda8f2 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-service-layer-2026-08-09.json @@ -0,0 +1,52 @@ +{ + "intent": "Give the enforcement decision a runtime that fires at the right moments, survives a reboot, and cannot be quietly deferred by Doze.", + "scope": [ + "focus_owner/android/.../EnforcementService.kt - new: applies a pass, schedules the next, stops", + "focus_owner/android/.../EnforcementScheduler.kt - new: exact alarms plus a coarse repair fallback", + "focus_owner/android/.../BootReceiver.kt - new: LOCKED_BOOT_COMPLETED and BOOT_COMPLETED", + "focus_owner/android/.../EnforcementAlarmReceiver.kt - new: hands off to the service", + "focus_owner/android/app/src/main/AndroidManifest.xml - permissions, service and receivers", + "focus_owner/lib/{main,device_policy}.dart - a Run-now control to arm the chain", + "Non-goal: no Device Owner provisioning; the service verifies this and no-ops" + ], + "changes": [ + "Not a polling loop. Doze can skip a poll indefinitely, and skipping the 05:00 boundary would leave the curfew allowlist applied into the morning with the user's banking app missing and no obvious cause. The transitions are known in advance, so they get exact alarms; the service wakes, applies, schedules and stops rather than sitting resident.", + "BootReceiver handles LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED. The latter does not arrive until first unlock - measured on this device, where it stayed RUNNING_LOCKED for minutes after a restart with no network. Relying on it alone freezes enforcement state across a reboot, which is safe for hiding but not for unhiding. The schedule is re-armed before unlock; the pass itself waits for storage to be readable.", + "USE_EXACT_ALARM replaced SCHEDULE_EXACT_ALARM after the first on-device run logged 'exact alarms not permitted'. dumpsys showed the cost precisely: window=+11m14s and maxWhenElapsed=+25m51s, so a 15-minute fallback could slip to 26 minutes. USE_EXACT_ALARM is granted at install for apps whose function is acting at specific times.", + "A failure inside a pass is caught and logged so it cannot prevent the next run being scheduled. An enforcement bug should degrade one tick, not end the chain." + ], + "verification": [ + { + "command": "adb install -r && am start && adb logcat", + "result": "pass", + "evidence": "'boot: android.intent.action.LOCKED_BOOT_COMPLETED unlocked=true' and 'BOOT_COMPLETED unlocked=true' both fire, followed by 'not DO/PO - no enforcement applied'. The service refuses to act unprovisioned and says so, rather than failing silently." + }, + { + "command": "adb shell dumpsys alarm | grep -A4 EnforcementAlarmReceiver (before)", + "result": "pass", + "evidence": "With SCHEDULE_EXACT_ALARM: 'window=+11m14s999ms', 'whenElapsed=+14m36s maxWhenElapsed=+25m51s'. This is the measurement that justified changing the permission." + }, + { + "command": "adb shell dumpsys alarm | grep -A4 EnforcementAlarmReceiver (after)", + "result": "pass", + "evidence": "'window=0 exactAllowReason=policy_permission' and whenElapsed == maxWhenElapsed (+14m43s984ms both). The boundary now fires on the minute, and the earlier warning is gone from logcat." + }, + { + "command": "cd focus_owner && flutter analyze && flutter test", + "result": "pass", + "evidence": "'No issues found!' and 67 tests passing." + } + ], + "risks": [ + "The service currently logs a tick and reschedules; it does not yet call setApplicationHidden with the decision. The decision layer and the DPM call are both tested, but the wiring between them is the next step and is not done.", + "QUERY_ALL_PACKAGES is now requested. It is necessary to enumerate installed apps for the allowlist sweep, but it is a broad permission and Play would require a declaration for it.", + "ACCESS_BACKGROUND_LOCATION is declared but not yet requested at runtime, and Android grants it only through a separate settings screen. Until that is wired the geofence cannot evaluate with the screen off.", + "The 15-minute repair fallback costs a wakeup every 15 minutes. Acceptable for bounding a lost alarm, but it should be widened once boundary alarms are proven reliable.", + "Still true and unproven: DO hiding in user 0." + ], + "rollback": [ + "git revert. The service no-ops without device owner, so reverting changes nothing on the phone.", + "To stop a scheduled chain on a device: open the app and use the cancel path, or uninstall - alarms do not survive uninstall.", + "After rollback validate: flutter analyze && flutter test pass, and dumpsys alarm shows no EnforcementAlarmReceiver entry." + ] +} diff --git a/focus_owner/android/app/src/main/AndroidManifest.xml b/focus_owner/android/app/src/main/AndroidManifest.xml index 28459d3a..6d122ac7 100644 --- a/focus_owner/android/app/src/main/AndroidManifest.xml +++ b/focus_owner/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +