Files
OpenAppLock/Shared/ScheduleEnforcement.swift
Brendan Chen 05e0ee2755 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.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 02:36:09 -04:00

38 lines
1.4 KiB
Swift

//
// ScheduleEnforcement.swift
// OpenAppLock
//
import Foundation
/// Background reaction for schedule (time-window) rules. The monitor extension
/// calls `reconcile` at each window boundary; it recomputes the rule's live
/// schedule state from its snapshot and applies or clears the shield to match.
///
/// This intentionally mirrors what `RuleEnforcer.refresh` does for schedule
/// rules in the foreground, so the background and foreground paths never
/// disagree. Recomputing (rather than blindly shielding on start / clearing on
/// end) also makes the two activities of a midnight-crossing window and any
/// late or duplicated interval callback converge on the correct state.
struct ScheduleEnforcement {
let snapshots: RuleSnapshotStore
let shields: ShieldApplying
func reconcile(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.kind == .schedule else {
return
}
if snapshot.isEnabled, !snapshot.isPaused(at: now),
snapshot.schedule.isActive(at: now, calendar: calendar) {
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
mode: snapshot.selectionMode,
blockAdultContent: snapshot.blockAdultContent
)
} else {
shields.clearShield(ruleID: snapshot.id)
}
}
}