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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Weekday> { 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"
|
||||
|
||||
37
Shared/ScheduleEnforcement.swift
Normal file
37
Shared/ScheduleEnforcement.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user