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.
86 lines
3.2 KiB
Swift
86 lines
3.2 KiB
Swift
//
|
|
// MonitoringPlan.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Naming conventions and event layouts shared by the app (which starts
|
|
/// DeviceActivity monitoring) and the monitor extension (which decodes what
|
|
/// fired).
|
|
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
|
|
/// the shield returns.
|
|
static let openSessionMinutes = 15
|
|
|
|
/// The always-on, midnight-to-midnight activity tracking a rule's day.
|
|
static func dailyActivityName(for ruleID: UUID) -> String {
|
|
dailyPrefix + ruleID.uuidString
|
|
}
|
|
|
|
/// The one-shot activity timing a granted open.
|
|
static func sessionActivityName(for ruleID: UUID) -> String {
|
|
sessionPrefix + ruleID.uuidString
|
|
}
|
|
|
|
static func ruleID(fromDailyActivityName name: String) -> UUID? {
|
|
guard name.hasPrefix(dailyPrefix) else { return nil }
|
|
return UUID(uuidString: String(name.dropFirst(dailyPrefix.count)))
|
|
}
|
|
|
|
static func ruleID(fromSessionActivityName name: String) -> UUID? {
|
|
guard name.hasPrefix(sessionPrefix) else { return nil }
|
|
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)
|
|
}
|
|
|
|
static func minutes(fromEventName name: String) -> Int? {
|
|
guard name.hasPrefix(minutePrefix) else { return nil }
|
|
return Int(name.dropFirst(minutePrefix.count))
|
|
}
|
|
|
|
/// Cumulative-usage checkpoints for a time-limit rule: one event per
|
|
/// minute up to the budget so remaining time can be displayed live; the
|
|
/// final one doubles as the block trigger. (Budgets cap at 240 minutes,
|
|
/// comfortably inside DeviceActivity's event capacity.)
|
|
static func minuteEvents(forLimit limitMinutes: Int) -> [String: Int] {
|
|
Dictionary(
|
|
uniqueKeysWithValues: (1...max(1, limitMinutes)).map {
|
|
(minuteEventName(for: $0), $0)
|
|
}
|
|
)
|
|
}
|
|
}
|