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.
269 lines
10 KiB
Swift
269 lines
10 KiB
Swift
//
|
|
// RuleScheduler.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import DeviceActivity
|
|
import FamilyControls
|
|
import Foundation
|
|
|
|
/// Abstracts `DeviceActivityCenter` so scheduling can be unit-tested.
|
|
protocol ActivityMonitoring: AnyObject {
|
|
/// Starts (or replaces) an always-on, midnight-to-midnight repeating
|
|
/// activity. `eventMinutes` maps event names to cumulative usage
|
|
/// thresholds (in minutes) over the rule's selection.
|
|
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 }
|
|
}
|
|
|
|
/// Mirrors rules into the shared snapshot store and reconciles
|
|
/// DeviceActivity monitoring with the enabled limit rules: each one gets a
|
|
/// daily activity (time limits with one usage checkpoint per budget minute).
|
|
/// Activities are only restarted when their configuration changes — a
|
|
/// restart resets checkpoint accounting to "usage from now on".
|
|
final class RuleScheduler {
|
|
private static let fingerprintsKey = "monitoringFingerprints"
|
|
|
|
private let monitor: ActivityMonitoring
|
|
private let snapshots: RuleSnapshotStore
|
|
private let defaults: UserDefaults
|
|
|
|
init(
|
|
monitor: ActivityMonitoring,
|
|
snapshots: RuleSnapshotStore = RuleSnapshotStore(),
|
|
defaults: UserDefaults = AppGroup.defaults
|
|
) {
|
|
self.monitor = monitor
|
|
self.snapshots = snapshots
|
|
self.defaults = defaults
|
|
}
|
|
|
|
func sync(rules: [BlockingRule], at now: Date = .now) {
|
|
snapshots.save(rules.map(RuleSnapshot.init))
|
|
|
|
var fingerprints = storedFingerprints
|
|
var desiredNames: Set<String> = []
|
|
|
|
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 }
|
|
|
|
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
|
|
|| MonitoringPlan.ruleID(fromScheduleWindowName: $0) != nil)
|
|
&& !desiredNames.contains($0)
|
|
}
|
|
if !stale.isEmpty {
|
|
monitor.stopMonitoring(names: stale)
|
|
for name in stale {
|
|
fingerprints[name] = nil
|
|
}
|
|
}
|
|
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) }
|
|
}
|
|
}
|
|
|
|
extension RuleSnapshot {
|
|
init(rule: BlockingRule) {
|
|
self.init(
|
|
id: rule.id,
|
|
name: rule.name,
|
|
kindRaw: rule.kindRaw,
|
|
isEnabled: rule.isEnabled,
|
|
hardMode: rule.hardMode,
|
|
blockAdultContent: rule.blockAdultContent,
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Real DeviceActivity scheduling. Each daily activity repeats from midnight
|
|
/// to 23:59 with usage-threshold events over the rule's selection.
|
|
final class DeviceActivityCenterMonitor: ActivityMonitoring {
|
|
private let center = DeviceActivityCenter()
|
|
|
|
var monitoredNames: [String] {
|
|
center.activities.map(\.rawValue)
|
|
}
|
|
|
|
func startDailyMonitoring(
|
|
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
|
) throws {
|
|
let selection = AppSelectionCodec.decode(selectionData)
|
|
let schedule = DeviceActivitySchedule(
|
|
intervalStart: DateComponents(hour: 0, minute: 0),
|
|
intervalEnd: DateComponents(hour: 23, minute: 59),
|
|
repeats: true
|
|
)
|
|
let events = Dictionary(
|
|
uniqueKeysWithValues: eventMinutes.map { eventName, minutes in
|
|
(
|
|
DeviceActivityEvent.Name(eventName),
|
|
DeviceActivityEvent(
|
|
applications: selection.applicationTokens,
|
|
categories: selection.categoryTokens,
|
|
webDomains: selection.webDomainTokens,
|
|
threshold: DateComponents(minute: minutes)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
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) })
|
|
}
|
|
}
|
|
|
|
/// 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] = []
|
|
|
|
func startDailyMonitoring(
|
|
name: String, selectionData: Data?, eventMinutes: [String: Int]
|
|
) throws {
|
|
startCallCount += 1
|
|
startedEvents[name] = eventMinutes
|
|
if !monitoredNames.contains(name) {
|
|
monitoredNames.append(name)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|