From 9092221d328f3550fb7865f4049e5adaafc0cd48 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sat, 13 Jun 2026 02:36:09 -0400 Subject: [PATCH] feat: enforce schedule rules in the background via DeviceActivity windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-, plus sched2- 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. --- AGENTS.md | 11 +- OpenAppLock/Services/RuleScheduler.swift | 136 ++++++-- .../DeviceActivityMonitorExtension.swift | 16 + OpenAppLockTests/SchedulingTests.swift | 315 +++++++++++++++++- Shared/MonitoringPlan.swift | 24 ++ Shared/RuleSnapshot.swift | 39 +++ Shared/ScheduleEnforcement.swift | 37 ++ docs/RULES_FEATURE_SPEC.md | 37 +- 8 files changed, 577 insertions(+), 38 deletions(-) create mode 100644 Shared/ScheduleEnforcement.swift diff --git a/AGENTS.md b/AGENTS.md index b542893..54e8101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,9 +140,14 @@ Gotchas learned the hard way: limits accrue in the Usage section and block at the budget; open-limit apps shield immediately with an "Open (N left)" button; an open lasts ~15 minutes (DeviceActivity's minimum interval) before re-shielding. -- **Schedule-rule background transitions** still rely on the app running - (launch / foreground 30s loop); schedule rules have no DeviceActivity - monitoring yet — only limit rules do. +- **Schedule-rule background transitions** are now backed by DeviceActivity: + `RuleScheduler` registers a repeating window activity per schedule rule + (`sched-`, plus `sched2-` for midnight-crossing windows) and the + monitor extension recomputes + applies/clears the shield on interval + start/end. The foreground 30s loop remains as the reconciliation safety net + because interval callbacks are unreliable. On-device verification of the + background transition is still pending (the simulator does not deliver + DeviceActivity callbacks). - `FamilyActivityPicker` shows few apps on the simulator; fine on device. - `FamilyActivityPicker` **silently ignores selections** (binding never updates, rows still show checkmarks) unless real FamilyControls diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index fd99371..7822edb 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -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 = [] - 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 } } } diff --git a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift index 5c7c2cb..dd75917 100644 --- a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift +++ b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift @@ -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) } } diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index 9749bae..826f46f 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -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.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.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) + } +} diff --git a/Shared/MonitoringPlan.swift b/Shared/MonitoringPlan.swift index 9d16adc..d17f39d 100644 --- a/Shared/MonitoringPlan.swift +++ b/Shared/MonitoringPlan.swift @@ -12,6 +12,8 @@ enum MonitoringPlan { private static let dailyPrefix = "rule-" private static let sessionPrefix = "open-session-" private static let minutePrefix = "minutes-" + private static let scheduleWindowPrefix = "sched-" + private static let scheduleWindowLatePrefix = "sched2-" /// Wall-clock length of one granted open. 15 minutes is DeviceActivity's /// minimum schedule interval, so an "open" lasts at most this long before @@ -38,6 +40,28 @@ enum MonitoringPlan { return UUID(uuidString: String(name.dropFirst(sessionPrefix.count))) } + /// The primary window activity for a schedule rule. A second + /// (`scheduleWindowLateName`) covers the post-midnight half of a window + /// that crosses midnight, since DeviceActivity can't express an interval + /// whose end is earlier than its start. + static func scheduleWindowName(for ruleID: UUID) -> String { + scheduleWindowPrefix + ruleID.uuidString + } + + static func scheduleWindowLateName(for ruleID: UUID) -> String { + scheduleWindowLatePrefix + ruleID.uuidString + } + + static func ruleID(fromScheduleWindowName name: String) -> UUID? { + if name.hasPrefix(scheduleWindowLatePrefix) { + return UUID(uuidString: String(name.dropFirst(scheduleWindowLatePrefix.count))) + } + if name.hasPrefix(scheduleWindowPrefix) { + return UUID(uuidString: String(name.dropFirst(scheduleWindowPrefix.count))) + } + return nil + } + static func minuteEventName(for minutes: Int) -> String { minutePrefix + String(minutes) } diff --git a/Shared/RuleSnapshot.swift b/Shared/RuleSnapshot.swift index cdbfd00..680066c 100644 --- a/Shared/RuleSnapshot.swift +++ b/Shared/RuleSnapshot.swift @@ -18,13 +18,23 @@ struct RuleSnapshot: Codable, Equatable { var selectionModeRaw: String var selectionData: Data? var dayNumbers: [Int] + /// Schedule-window bounds, minutes from midnight (mirrors `BlockingRule`). + /// Only meaningful for `.schedule` rules; limit rules carry 0/0. + var startMinutes: Int + var endMinutes: Int var dailyLimitMinutes: Int var maxOpens: Int var pausedUntil: Date? var kind: RuleKind { RuleKind(rawValue: kindRaw) ?? .schedule } + var selectionMode: SelectionMode { SelectionMode(rawValue: selectionModeRaw) ?? .block } var days: Set { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) } + /// The recurring time window this rule blocks, for schedule rules. + var schedule: RuleSchedule { + RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days) + } + func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool { guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else { return false @@ -48,6 +58,35 @@ struct RuleSnapshot: Codable, Equatable { } } +extension RuleSnapshot { + private enum CodingKeys: String, CodingKey { + case id, name, kindRaw, isEnabled, hardMode, blockAdultContent + case selectionModeRaw, selectionData, dayNumbers, startMinutes, endMinutes + case dailyLimitMinutes, maxOpens, pausedUntil + } + + /// Decodes tolerantly so snapshots written before `startMinutes`/`endMinutes` + /// existed still load (defaulting the window to 0) instead of failing the + /// whole batch — which would blind the extensions until the app reopened. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + kindRaw = try container.decode(String.self, forKey: .kindRaw) + isEnabled = try container.decode(Bool.self, forKey: .isEnabled) + hardMode = try container.decode(Bool.self, forKey: .hardMode) + blockAdultContent = try container.decode(Bool.self, forKey: .blockAdultContent) + selectionModeRaw = try container.decode(String.self, forKey: .selectionModeRaw) + selectionData = try container.decodeIfPresent(Data.self, forKey: .selectionData) + dayNumbers = try container.decode([Int].self, forKey: .dayNumbers) + startMinutes = try container.decodeIfPresent(Int.self, forKey: .startMinutes) ?? 0 + endMinutes = try container.decodeIfPresent(Int.self, forKey: .endMinutes) ?? 0 + dailyLimitMinutes = try container.decode(Int.self, forKey: .dailyLimitMinutes) + maxOpens = try container.decode(Int.self, forKey: .maxOpens) + pausedUntil = try container.decodeIfPresent(Date.self, forKey: .pausedUntil) + } +} + /// Persistence for the rule mirror in the shared app-group defaults. final class RuleSnapshotStore { private static let key = "ruleSnapshots" diff --git a/Shared/ScheduleEnforcement.swift b/Shared/ScheduleEnforcement.swift new file mode 100644 index 0000000..4536f3c --- /dev/null +++ b/Shared/ScheduleEnforcement.swift @@ -0,0 +1,37 @@ +// +// 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) + } + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 19916a8..bcd3820 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -378,16 +378,39 @@ enum SelectionMode: String, Codable { case block, allowOnly } by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule, per-day minutes/opens), and the shield-store tracking list. - **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the - enabled limit rules: one repeating 00:00–23:59 activity per rule - (`rule-`); time-limit rules carry one cumulative usage-threshold - event per budget minute (`minutes-`) over the rule's app list. + enabled rules: + - **Limit rules** — one repeating 00:00–23:59 activity per rule + (`rule-`); time-limit rules carry one cumulative usage-threshold + event per budget minute (`minutes-`) over the rule's app list. + - **Schedule rules** — one (or, for windows that cross midnight, two) + repeating window activit(ies) per rule matching the rule's + `From…To` window (`sched-` and, for the post-midnight half, + `sched2-`). These carry no events; they exist purely to wake the + monitor at the window edges so shields engage **in the background even + when the app is closed**. A window that ends exactly at midnight, or is + shorter than DeviceActivity's 15-minute minimum interval, may fail to + register (`intervalTooShort`) and falls back to the foreground loop. Activities restart only when their configuration changes, because a restart resets threshold accounting. - **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start - = midnight reset (open-limit rules re-shield so opens can be counted; - time-limit shields clear for the fresh budget); each `minutes-` event - records usage and shields at the budget; a finished `open-session-` - one-shot re-shields after a granted open. + = midnight reset for limit rules (open-limit rules re-shield so opens can + be counted; time-limit shields clear for the fresh budget); each + `minutes-` event records usage and shields at the budget; a finished + `open-session-` one-shot re-shields after a granted open. For + schedule-window activities (`sched-`/`sched2-`), **both** interval start + and interval end **recompute** the rule's live schedule state from its + snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the + midnight-crossing rule) and apply or clear the shield accordingly — the + same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths + agree. +- **Reliability posture** — DeviceActivity interval callbacks are + "first device use after the boundary", are known to fire late or be + skipped (device asleep, OS regressions on iOS 17/18/26), and a shield + written over an app the user already has open may not visibly engage until + that app is relaunched (a long-standing Screen Time platform limitation). + Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh` + (launch + 30 s foreground loop) is retained as the reconciliation safety + net and is the source of truth whenever the app runs. - **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit shields show "Opened X of N times today" with an "Open (Y left)" secondary button; other shields show the blocking rule's name.