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:
@@ -378,16 +378,39 @@ enum SelectionMode: String, Codable { case block, allowOnly }
|
||||
by `RuleScheduler` on every enforcement refresh), `UsageLedger` (per-rule,
|
||||
per-day minutes/opens), and the shield-store tracking list.
|
||||
- **`RuleScheduler` (app)** reconciles DeviceActivity monitoring with the
|
||||
enabled limit rules: one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
enabled rules:
|
||||
- **Limit rules** — one repeating 00:00–23:59 activity per rule
|
||||
(`rule-<uuid>`); time-limit rules carry one cumulative usage-threshold
|
||||
event per budget minute (`minutes-<k>`) over the rule's app list.
|
||||
- **Schedule rules** — one (or, for windows that cross midnight, two)
|
||||
repeating window activit(ies) per rule matching the rule's
|
||||
`From…To` window (`sched-<uuid>` and, for the post-midnight half,
|
||||
`sched2-<uuid>`). These carry no events; they exist purely to wake the
|
||||
monitor at the window edges so shields engage **in the background even
|
||||
when the app is closed**. A window that ends exactly at midnight, or is
|
||||
shorter than DeviceActivity's 15-minute minimum interval, may fail to
|
||||
register (`intervalTooShort`) and falls back to the foreground loop.
|
||||
Activities restart only when their configuration changes, because a
|
||||
restart resets threshold accounting.
|
||||
- **`OpenAppLockMonitor`** (DeviceActivityMonitor extension): interval start
|
||||
= midnight reset (open-limit rules re-shield so opens can be counted;
|
||||
time-limit shields clear for the fresh budget); each `minutes-<k>` event
|
||||
records usage and shields at the budget; a finished `open-session-<uuid>`
|
||||
one-shot re-shields after a granted open.
|
||||
= midnight reset for limit rules (open-limit rules re-shield so opens can
|
||||
be counted; time-limit shields clear for the fresh budget); each
|
||||
`minutes-<k>` event records usage and shields at the budget; a finished
|
||||
`open-session-<uuid>` one-shot re-shields after a granted open. For
|
||||
schedule-window activities (`sched-`/`sched2-`), **both** interval start
|
||||
and interval end **recompute** the rule's live schedule state from its
|
||||
snapshot (`RuleSchedule.isActive`, honouring enabled days, pause and the
|
||||
midnight-crossing rule) and apply or clear the shield accordingly — the
|
||||
same logic `RuleEnforcer.refresh` runs in the foreground, so the two paths
|
||||
agree.
|
||||
- **Reliability posture** — DeviceActivity interval callbacks are
|
||||
"first device use after the boundary", are known to fire late or be
|
||||
skipped (device asleep, OS regressions on iOS 17/18/26), and a shield
|
||||
written over an app the user already has open may not visibly engage until
|
||||
that app is relaunched (a long-standing Screen Time platform limitation).
|
||||
Background monitoring is therefore **best-effort**; `RuleEnforcer.refresh`
|
||||
(launch + 30 s foreground loop) is retained as the reconciliation safety
|
||||
net and is the source of truth whenever the app runs.
|
||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
||||
button; other shields show the blocking rule's name.
|
||||
|
||||
Reference in New Issue
Block a user