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:
@@ -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:00–17: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:00–06: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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user