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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user