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>
This commit is contained in:
2026-06-13 02:36:09 -04:00
parent 443b37c5e1
commit 05e0ee2755
8 changed files with 577 additions and 38 deletions

View File

@@ -25,12 +25,14 @@ struct RuleSnapshotTests {
let snapshot = RuleSnapshot(
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: [2, 3], dailyLimitMinutes: 45, maxOpens: 5,
pausedUntil: nil
selectionData: Data([1]), dayNumbers: [2, 3], startMinutes: 540, endMinutes: 1020,
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: nil
)
store.save([snapshot])
#expect(store.load() == [snapshot])
#expect(store.snapshot(for: snapshot.id) == snapshot)
#expect(store.snapshot(for: snapshot.id)?.startMinutes == 540)
#expect(store.snapshot(for: snapshot.id)?.endMinutes == 1020)
#expect(store.snapshot(for: UUID()) == nil)
}
@@ -50,6 +52,29 @@ struct RuleSnapshotTests {
#expect(snapshot.selectionData == Data([9]))
#expect(snapshot.days == Weekday.weekends)
#expect(snapshot.maxOpens == 3)
#expect(snapshot.startMinutes == rule.startMinutes)
#expect(snapshot.endMinutes == rule.endMinutes)
}
@Test("Snapshots saved before the window fields decode with a zeroed window")
func decodesLegacySnapshotWithoutWindowFields() throws {
// A blob shaped like a pre-schedule-window snapshot: no start/endMinutes.
let id = UUID()
let legacy = """
[{"id":"\(id.uuidString)","name":"Old","kindRaw":"timeLimit","isEnabled":true,\
"hardMode":false,"blockAdultContent":false,"selectionModeRaw":"block",\
"dayNumbers":[2,3],"dailyLimitMinutes":45,"maxOpens":5}]
"""
let defaults = freshDefaults()
defaults.set(Data(legacy.utf8), forKey: "ruleSnapshots")
let store = RuleSnapshotStore(defaults: defaults)
let loaded = store.load()
#expect(loaded.count == 1)
#expect(loaded.first?.id == id)
#expect(loaded.first?.startMinutes == 0)
#expect(loaded.first?.endMinutes == 0)
#expect(loaded.first?.dailyLimitMinutes == 45)
}
}
@@ -71,6 +96,22 @@ struct MonitoringPlanTests {
fromSessionActivityName: MonitoringPlan.dailyActivityName(for: id)) == nil)
}
@Test("Schedule-window activity names round-trip rule IDs")
func scheduleWindowNameRoundTrip() {
let id = UUID()
let primary = MonitoringPlan.scheduleWindowName(for: id)
let late = MonitoringPlan.scheduleWindowLateName(for: id)
#expect(primary != late)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: primary) == id)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: late) == id)
// Daily / session / garbage names are not schedule-window names.
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: MonitoringPlan.dailyActivityName(for: id)) == nil)
#expect(MonitoringPlan.ruleID(fromScheduleWindowName: "garbage") == nil)
// Schedule-window names are not mistaken for daily activities.
#expect(MonitoringPlan.ruleID(fromDailyActivityName: primary) == nil)
#expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil)
}
@Test("Minute checkpoints cover every minute up to the budget")
func minuteEvents() {
let events = MonitoringPlan.minuteEvents(forLimit: 45)
@@ -101,6 +142,22 @@ struct RuleSchedulerTests {
return rule
}
private func scheduleRule(
name: String, start: Int, end: Int, days: Set<Weekday> = Weekday.everyDay,
withApps: Bool = true
) throws -> BlockingRule {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: name, kind: .schedule, days: days, startMinutes: start, endMinutes: end)
context.insert(rule)
if withApps {
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
context.insert(list)
rule.appList = list
}
return rule
}
@Test("Enabled limit rules with apps start daily monitoring")
func startsMonitoring() throws {
let (scheduler, monitor, store) = makeScheduler()
@@ -124,20 +181,153 @@ struct RuleSchedulerTests {
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true)
}
@Test("Schedule rules and app-less limit rules are not monitored")
@Test("App-less rules (schedule or limit) are not monitored")
func skipsUnmonitorable() throws {
let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext()
let scheduleRule = BlockingRule(name: "Work Time")
let appless = BlockingRule(name: "Empty", kind: .timeLimit)
context.insert(scheduleRule)
context.insert(appless)
let applessSchedule = BlockingRule(name: "Work Time")
let applessLimit = BlockingRule(name: "Empty", kind: .timeLimit)
context.insert(applessSchedule)
context.insert(applessLimit)
scheduler.sync(rules: [scheduleRule, appless])
scheduler.sync(rules: [applessSchedule, applessLimit])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("A non-crossing schedule rule registers one window activity")
func schedulesNonCrossingWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 9 * 60)
#expect(monitor.startedWindows[primary]?.end == 17 * 60)
// A schedule window carries no usage-threshold events.
#expect(monitor.startedEvents[primary] == nil)
}
@Test("A dayless schedule rule is not monitored")
func skipsDaylessSchedule() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "No Days", start: 9 * 60, end: 17 * 60, days: [])
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("A midnight-crossing schedule rule registers two window activities")
func schedulesCrossingWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(Set(monitor.monitoredNames) == [primary, late])
// The evening half runs to the end of the day; the morning half from midnight.
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
#expect(monitor.startedWindows[late]?.start == 0)
#expect(monitor.startedWindows[late]?.end == 6 * 60)
}
@Test("A window ending exactly at midnight registers a single evening activity")
func scheduleWindowEndingAtMidnight() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Late", start: 22 * 60, end: 0)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 22 * 60)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
#expect(monitor.startedWindows[late] == nil)
}
@Test("A 24-hour window registers a single all-day activity")
func scheduleFullDayWindow() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Always", start: 0, end: 0)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[primary]?.start == 0)
#expect(monitor.startedWindows[primary]?.end == 24 * 60 - 1)
}
@Test("Switching a window from crossing to non-crossing stops the morning half")
func dropsLateActivityWhenWindowStopsCrossing() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Deep Sleep", start: 22 * 60, end: 6 * 60)
scheduler.sync(rules: [rule])
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
let late = MonitoringPlan.scheduleWindowLateName(for: rule.id)
#expect(Set(monitor.monitoredNames) == [primary, late])
// Now a normal daytime window the post-midnight half must be stopped.
rule.startMinutes = 9 * 60
rule.endMinutes = 17 * 60
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames == [primary])
#expect(monitor.startedWindows[late] == nil)
}
@Test("Changing only the days does not restart the window activity")
func keepsWindowWhenOnlyDaysChange() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(
name: "Work Time", start: 9 * 60, end: 17 * 60, days: Weekday.weekdays)
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
// The window interval is unchanged, so the DeviceActivity activity that
// only encodes start/end need not restart reconcile() reads days fresh.
rule.days = Weekday.everyDay
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
}
@Test("Disabling a schedule rule stops its window monitoring")
func stopsScheduleWindowWhenDisabled() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
#expect(!monitor.monitoredNames.isEmpty)
rule.isEnabled = false
scheduler.sync(rules: [rule])
#expect(monitor.monitoredNames.isEmpty)
}
@Test("Changing a schedule window restarts monitoring")
func restartsOnWindowChange() throws {
let (scheduler, monitor, _) = makeScheduler()
let rule = try scheduleRule(name: "Work Time", start: 9 * 60, end: 17 * 60)
scheduler.sync(rules: [rule])
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 1)
rule.endMinutes = 18 * 60
scheduler.sync(rules: [rule])
#expect(monitor.startCallCount == 2)
let primary = MonitoringPlan.scheduleWindowName(for: rule.id)
#expect(monitor.startedWindows[primary]?.end == 18 * 60)
}
@Test("Monitoring stops when a rule is disabled or removed")
func stopsStaleMonitoring() throws {
let (scheduler, monitor, _) = makeScheduler()
@@ -188,6 +378,7 @@ struct LimitEnforcementTests {
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
startMinutes: 0, endMinutes: 0,
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
)
}
@@ -277,3 +468,111 @@ struct LimitEnforcementTests {
#expect(shields.shieldedRuleIDs.isEmpty)
}
}
@MainActor
@Suite("Schedule-window enforcement reactions")
struct ScheduleEnforcementTests {
/// Monday 2025-01-06 10:00 UTC sits inside a 09:0017:00 weekday window.
let mondayMorning = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
private func makeEnforcement() -> (ScheduleEnforcement, MockShieldController, RuleSnapshotStore) {
let shields = MockShieldController()
let store = RuleSnapshotStore(defaults: freshDefaults())
return (ScheduleEnforcement(snapshots: store, shields: shields), shields, store)
}
private func snapshot(
start: Int = 9 * 60, end: Int = 17 * 60, days: Set<Weekday> = Weekday.everyDay,
isEnabled: Bool = true, mode: SelectionMode = .block, pausedUntil: Date? = nil
) -> RuleSnapshot {
RuleSnapshot(
id: UUID(), name: "Work Time", kindRaw: RuleKind.schedule.rawValue,
isEnabled: isEnabled, hardMode: false, blockAdultContent: false,
selectionModeRaw: mode.rawValue, selectionData: Data([1]),
dayNumbers: days.map(\.rawValue), startMinutes: start, endMinutes: end,
dailyLimitMinutes: 45, maxOpens: 5, pausedUntil: pausedUntil
)
}
@Test("Reconcile shields a rule whose window is active now")
func shieldsActiveWindow() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot()
store.save([snap])
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("Reconcile clears a rule whose window is over")
func clearsInactiveWindow() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot()
store.save([snap])
shields.applyShield(
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
enforcement.reconcile(ruleID: snap.id, now: mondayEvening, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Reconcile forwards the rule's selection mode")
func forwardsSelectionMode() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot(mode: .allowOnly)
store.save([snap])
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
#expect(shields.appliedModes[snap.id] == .allowOnly)
}
@Test("A disabled schedule rule is never shielded")
func skipsDisabled() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot(isEnabled: false)
store.save([snap])
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("A paused (unblocked) schedule rule is left clear")
func skipsPaused() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot(pausedUntil: date(2025, 1, 6, 17, 0))
store.save([snap])
enforcement.reconcile(ruleID: snap.id, now: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("A midnight-crossing window is active in the early morning")
func shieldsCrossingWindowAfterMidnight() {
let (enforcement, shields, store) = makeEnforcement()
// 22:0006:00 every day; 02:00 belongs to the window that started the
// previous evening (so the previous day must be enabled it is).
let snap = snapshot(start: 22 * 60, end: 6 * 60)
store.save([snap])
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 6, 2, 0), calendar: utc)
#expect(shields.shieldedRuleIDs == [snap.id])
}
@Test("A weekday window is inactive on the weekend")
func clearsOnDisabledDay() {
let (enforcement, shields, store) = makeEnforcement()
let snap = snapshot(days: Weekday.weekdays)
store.save([snap])
// 2025-01-11 is a Saturday.
enforcement.reconcile(ruleID: snap.id, now: date(2025, 1, 11, 10, 0), calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
}