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:
2026-06-13 02:36:09 -04:00
parent 1652c2f410
commit 9092221d32
8 changed files with 577 additions and 38 deletions

View File

@@ -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"