feat: enforce schedule rules in the background via DeviceActivity windows

Schedule (time-window) rules had no background enforcement: RuleScheduler.sync()
skipped them, so their shields were applied only by RuleEnforcer.refresh() — the
launch + 30s foreground loop. A window that began while the app was closed didn't
engage until the user reopened the app, which is why scheduled blocks could land
late or unevenly.

RuleScheduler now registers a repeating DeviceActivitySchedule per enabled
schedule rule's window (sched-<uuid>, plus sched2-<uuid> for windows that cross
midnight, since DeviceActivity can't express an interval whose end precedes its
start). The monitor extension routes these to a new ScheduleEnforcement.reconcile(),
which recomputes the rule's live schedule state from its snapshot
(RuleSchedule.isActive, honouring days, pause and the midnight-crossing rule) and
applies or clears the shield to match — the same logic RuleEnforcer.refresh runs
in the foreground, kept as the reconciliation safety net because interval callbacks
are known to fire late or not at all.

- RuleSnapshot gains startMinutes/endMinutes, with a tolerant decoder so snapshots
  written before these fields still load instead of failing the whole batch and
  blinding the extensions until the app reopens.
- Window activities are fingerprinted on their interval alone, so changing days,
  mode or apps no longer needlessly restarts monitoring.

On-device verification of the background transition is still pending (the simulator
does not deliver DeviceActivity callbacks); covered by new unit tests across the
scheduler, the snapshot codec and the new enforcement reactions.
This commit is contained in:
2026-06-13 02:36:09 -04:00
parent 1652c2f410
commit 9092221d32
8 changed files with 577 additions and 38 deletions

View File

@@ -19,10 +19,21 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
)
}
private var scheduleEnforcement: ScheduleEnforcement {
ScheduleEnforcement(
snapshots: RuleSnapshotStore(),
shields: ManagedSettingsShieldController()
)
}
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
enforcement.handleDayStart(ruleID: ruleID)
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
// A schedule window opened: shield it (the recompute honours days,
// pause and the midnight-crossing rule).
scheduleEnforcement.reconcile(ruleID: ruleID)
}
}
@@ -31,6 +42,11 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
if let ruleID = MonitoringPlan.ruleID(fromSessionActivityName: activity.rawValue) {
enforcement.handleOpenSessionEnded(ruleID: ruleID)
DeviceActivityCenter().stopMonitoring([activity])
} else if let ruleID = MonitoringPlan.ruleID(fromScheduleWindowName: activity.rawValue) {
// A schedule window closed (or its evening half ended at 23:59):
// recompute so a still-active window stays shielded and a finished
// one clears.
scheduleEnforcement.reconcile(ruleID: ruleID)
}
}