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

@@ -15,6 +15,13 @@ protocol ActivityMonitoring: AnyObject {
func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws
/// Starts (or replaces) a repeating window activity spanning
/// `intervalStartMinutes``intervalEndMinutes` (minutes from midnight),
/// carrying no events used to wake the monitor at a schedule rule's
/// window edges so its shield engages in the background.
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws
func stopMonitoring(names: [String])
var monitoredNames: [String] { get }
}
@@ -47,32 +54,50 @@ final class RuleScheduler {
var fingerprints = storedFingerprints
var desiredNames: Set<String> = []
for rule in rules where rule.kind != .schedule {
guard rule.isEnabled, let selectionData = rule.appList?.selectionData else { continue }
let name = MonitoringPlan.dailyActivityName(for: rule.id)
desiredNames.insert(name)
let events =
rule.kind == .timeLimit
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
: [:]
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
+ "\(selectionData.hashValue)"
guard fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
for rule in rules {
// A rule must be enabled, have days, and have apps to be monitored.
guard rule.isEnabled, !rule.days.isEmpty,
let selectionData = rule.appList?.selectionData
else { continue }
do {
try monitor.startDailyMonitoring(
name: name, selectionData: selectionData, eventMinutes: events)
fingerprints[name] = fingerprint
} catch {
// Monitoring is best-effort here; the next sync retries.
// (Throws on the simulator and when authorization is missing.)
switch rule.kind {
case .timeLimit, .openLimit:
let name = MonitoringPlan.dailyActivityName(for: rule.id)
desiredNames.insert(name)
let events =
rule.kind == .timeLimit
? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes)
: [:]
let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|"
+ "\(selectionData.hashValue)"
guard needsRestart(name, fingerprint, in: fingerprints) else { continue }
start(name: name) {
try monitor.startDailyMonitoring(
name: name, selectionData: selectionData, eventMinutes: events)
} onStarted: { fingerprints[name] = fingerprint }
case .schedule:
// A window activity encodes only its interval (no events, no
// selection); days, mode and apps are read fresh by reconcile()
// at each callback, so only a start/end change needs a restart.
let fingerprint = "schedule|\(rule.startMinutes)|\(rule.endMinutes)"
for window in scheduleWindows(for: rule) {
desiredNames.insert(window.name)
guard needsRestart(window.name, fingerprint, in: fingerprints) else { continue }
start(name: window.name) {
try monitor.startWindowMonitoring(
name: window.name,
intervalStartMinutes: window.start,
intervalEndMinutes: window.end)
} onStarted: { fingerprints[window.name] = fingerprint }
}
}
}
let stale = monitor.monitoredNames.filter {
MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil && !desiredNames.contains($0)
(MonitoringPlan.ruleID(fromDailyActivityName: $0) != nil
|| MonitoringPlan.ruleID(fromScheduleWindowName: $0) != nil)
&& !desiredNames.contains($0)
}
if !stale.isEmpty {
monitor.stopMonitoring(names: stale)
@@ -83,6 +108,50 @@ final class RuleScheduler {
storedFingerprints = fingerprints
}
/// Whether `name` should be (re)started: its configuration changed, or the
/// system isn't actually monitoring it (e.g. a prior start threw).
private func needsRestart(
_ name: String, _ fingerprint: String, in fingerprints: [String: String]
) -> Bool {
fingerprints[name] != fingerprint || !monitor.monitoredNames.contains(name)
}
/// Runs a best-effort `startMonitoring` call. Monitoring throws on the
/// simulator, when authorization is missing, and when the activity cap or
/// minimum interval is exceeded; the next sync retries.
private func start(name: String, _ body: () throws -> Void, onStarted: () -> Void) {
do {
try body()
onStarted()
} catch {
// Best-effort; the foreground reconciliation loop is the safety net.
}
}
/// The DeviceActivity window activities for a schedule rule. Normal windows
/// map to one activity; midnight-crossing windows split into an evening half
/// (to 23:59) and a morning half (from 00:00); a `start == end` window is
/// treated as all-day.
private func scheduleWindows(for rule: BlockingRule) -> [(name: String, start: Int, end: Int)] {
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
let endOfDay = 24 * 60 - 1
let start = rule.startMinutes
let end = rule.endMinutes
if start < end {
return [(name: primary, start: start, end: end)]
}
if start == end {
return [(name: primary, start: 0, end: endOfDay)]
}
var windows = [(name: primary, start: start, end: endOfDay)]
if end > 0 {
windows.append((name: late, start: 0, end: end))
}
return windows
}
private var storedFingerprints: [String: String] {
get { defaults.dictionary(forKey: Self.fingerprintsKey) as? [String: String] ?? [:] }
set { defaults.set(newValue, forKey: Self.fingerprintsKey) }
@@ -101,6 +170,8 @@ extension RuleSnapshot {
selectionModeRaw: rule.selectionModeRaw,
selectionData: rule.appList?.selectionData,
dayNumbers: rule.dayNumbers,
startMinutes: rule.startMinutes,
endMinutes: rule.endMinutes,
dailyLimitMinutes: rule.dailyLimitMinutes,
maxOpens: rule.maxOpens,
pausedUntil: rule.pausedUntil
@@ -142,6 +213,19 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
}
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws {
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(
hour: intervalStartMinutes / 60, minute: intervalStartMinutes % 60),
intervalEnd: DateComponents(
hour: intervalEndMinutes / 60, minute: intervalEndMinutes % 60),
repeats: true
)
try center.startMonitoring(DeviceActivityName(name), during: schedule)
}
func stopMonitoring(names: [String]) {
center.stopMonitoring(names.map { DeviceActivityName($0) })
}
@@ -150,6 +234,7 @@ final class DeviceActivityCenterMonitor: ActivityMonitoring {
/// Records scheduling calls for tests.
final class MockActivityMonitor: ActivityMonitoring {
private(set) var startedEvents: [String: [String: Int]] = [:]
private(set) var startedWindows: [String: (start: Int, end: Int)] = [:]
private(set) var startCallCount = 0
private(set) var monitoredNames: [String] = []
@@ -163,10 +248,21 @@ final class MockActivityMonitor: ActivityMonitoring {
}
}
func startWindowMonitoring(
name: String, intervalStartMinutes: Int, intervalEndMinutes: Int
) throws {
startCallCount += 1
startedWindows[name] = (intervalStartMinutes, intervalEndMinutes)
if !monitoredNames.contains(name) {
monitoredNames.append(name)
}
}
func stopMonitoring(names: [String]) {
monitoredNames.removeAll(where: names.contains)
for name in names {
startedEvents[name] = nil
startedWindows[name] = nil
}
}
}