diff --git a/AGENTS.md b/AGENTS.md index ef8103b..4dd78bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,12 +23,14 @@ OpenAppLock/ App target (iOS 26, SwiftUI + SwiftData) SampleRules (UI-test harness) Views/ Native SwiftUI screens (spec in each view's doc comment; see "Rules feature map" below) -Shared/ Compiled into the app AND all three extensions: +Shared/ Compiled into the app AND all four extensions: RuleKind, Weekday, RuleSchedule, AppGroup, - UsageLedger (per-day minutes/opens), + UsageLedger (per-day minutes/opens + the report's + authoritative daily total), RuleSnapshot(+Store) (rule mirror in the app group), MonitoringPlan (activity/event naming), LimitEnforcement (shared event reactions), + DayStartStore (confirmed daily-activity starts), ShieldController, ShieldLookup OpenAppLockMonitor/ DeviceActivityMonitor extension: midnight resets, usage-minute checkpoints → shield at the limit, @@ -37,21 +39,31 @@ OpenAppLockShieldConfig/ ShieldConfiguration extension: "Opened X of N" + Open button on open-limit shields OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open, lifts the shield, starts the ~15-min session +OpenAppLockReport/ DeviceActivityReport extension: computes each + time-limit rule's true daily usage (foreground only) + and writes it to UsageLedger as the authoritative + figure OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app target defaults to MainActor isolation) OpenAppLockUITests/ XCUITest flows (see harness below) Docs/AGENT_SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards agents must follow on this project (agent-managed). +Docs/Agents/ Agent working docs — the whole folder is + agent-modifiable. Design specs live under + Docs/Agents/Specs/ (agent-managed). ``` ## Documentation Documentation falls into three buckets: -- **Agent-managed** — this `AGENTS.md`, `CLAUDE.md`, and any file whose name is - prefixed with `AGENT_` (currently `Docs/AGENT_SWIFT_GUIDELINES.md`). Agents may - **read, create, and edit** these and are expected to keep them accurate. +- **Agent-managed** — this `AGENTS.md`, `CLAUDE.md`, any file whose name is + prefixed with `AGENT_` (currently `Docs/AGENT_SWIFT_GUIDELINES.md`), and + **anything under `Docs/Agents/`** (e.g. design specs in `Docs/Agents/Specs/`, + plans in `Docs/Agents/Plans/`) — the folder marks ownership by location, so + files inside it need no `AGENT_` prefix. Agents may **read, create, and edit** + these and are expected to keep them accurate. - **Shared (human + agent)** — the rules feature spec. It lives as doc comments **on the source each behavior owns**; both humans and agents maintain it. The doc comments are the source of truth for behavior — when you change a behavior, @@ -119,6 +131,7 @@ Where each topic is documented: | Time/open-limit behavior, granted opens, proactive gate | `Shared/LimitEnforcement.swift`, `Shared/UsageLedger.swift`, `Shared/OpenSessionStore.swift` | | Shield text + "Open" button / press handling | `Shared/ShieldPresentation.swift`, `OpenAppLockShieldConfig/ShieldConfigurationExtension.swift`, `OpenAppLockShieldAction/ShieldActionExtension.swift` | | DeviceActivity scheduling, naming; background monitor | `OpenAppLock/Services/RuleScheduler.swift`, `Shared/MonitoringPlan.swift`, `OpenAppLockMonitor/DeviceActivityMonitorExtension.swift` | +| Authoritative time-limit usage report; confirmed day-start gate | `OpenAppLockReport/RuleUsageReport.swift`, `Shared/DayStartStore.swift`, `OpenAppLock/Views/MainView.swift` | | Uninstall Protection | `OpenAppLock/Views/Settings/SettingsView.swift`, `Shared/UninstallProtectionPolicy.swift`, `Shared/UninstallProtectionEnforcer.swift`, `OpenAppLock/Services/AppSettings.swift` | | About links (GitHub / Website) | `OpenAppLock/Services/AppLinks.swift`, `OpenAppLock/Services/LaunchConfiguration.swift` | @@ -240,6 +253,21 @@ Gotchas learned the hard way: limits accrue in the Usage section and block at the budget; open-limit apps shield immediately with an "Open (N left)" button; an open lasts ~15 minutes (DeviceActivity's minimum interval) before re-shielding. +- **Time-limit counting hardening** (see + `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`) is implemented but + device-verification is pending. Time limits now register a **single** + `minutes-` block event (not a per-minute chain); the monitor records + usage only for rules eligible today and only after a **confirmed** + daily-activity start (`DayStartStore`), dropping stale cross-midnight + flushes. The new **`OpenAppLockReport`** DeviceActivityReport extension + computes each rule's true daily total while the app is foreground and writes + it to `UsageLedger`; display and the foreground block decision prefer that + authoritative figure when fresh. Verify on device: the Usage counter shows + the true total on app open (no "stalls at ~14/15m" lag); a maxed-out day + does not re-block unused apps the next morning (or clears within one + foreground refresh); report attribution covers category/web-domain + selections (currently only application tokens are summed); and tune + `RuleUsage.authoritativeFreshness` (120s) so the foreground stays fresh. - **Schedule-rule background transitions** are now backed by DeviceActivity: `RuleScheduler` registers a repeating window activity per schedule rule (`sched-`, plus `sched2-` for midnight-crossing windows) and the diff --git a/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md b/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md new file mode 100644 index 0000000..45457d8 --- /dev/null +++ b/Docs/Agents/Plans/TIME_LIMIT_COUNTING_HARDENING.md @@ -0,0 +1,1093 @@ +# Time-Limit Counting Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make time-limit enforcement robust against batched/late Screen Time threshold events — stop phantom blocks and under-counts, and provide an authoritative foreground usage total. + +**Architecture:** Two complementary parts. Part A hardens the background path (record only for eligible rules; gate usage on a confirmed day-start) so stale cross-midnight flushes can't corrupt the ledger. Part B adds a `DeviceActivityReport` extension that computes the true daily total while the app is foreground and writes it to the app group; the app prefers that authoritative figure for display and the foreground block decision. See `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`. + +**Tech Stack:** Swift 6, SwiftUI, SwiftData, FamilyControls / DeviceActivity / ManagedSettings (Screen Time), Swift Testing. + +## Global Constraints + +- **Tests:** Swift Testing (`import Testing`, `@Test`, `#expect`), `@MainActor` suites. Reuse `date()`, `utc`, `makeInMemoryContext()` from `OpenAppLockTests/TestSupport.swift`; create isolated `UserDefaults` per test (`UserDefaults(suiteName: "…-\(UUID())")!`). +- **Build & test:** via the Xcode MCP only (`BuildProject`, `RunSomeTests`, `RunAllTests`) on a **simulator** destination. Never invoke raw `xcodebuild`. `plutil -lint` is allowed for plist/pbxproj syntax checks (read-only). +- **Style:** value types, `let` over `var`, immutability; small focused files; no `print()` (use `os.Logger` if logging needed). +- **App group:** `group.dev.bchen.OpenAppLock`. **Report bundle id:** `dev.bchen.OpenAppLock.Report`. **Entitlements:** `com.apple.developer.family-controls` + the app group, on every extension. +- **Authoritative freshness window:** `120` seconds (named constant, tunable on device). +- **Commits:** Conventional Commits. End every commit message with: + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + Claude-Session: https://claude.ai/code/session_01TD4vdHbB8KqLPGYNbYNS5U + ``` +- **Branch:** `feat/time-limit-counting-hardening` (already created). + +--- + +## Task A1: DayStartStore + +**Files:** +- Create: `Shared/DayStartStore.swift` +- Test: `OpenAppLockTests/SchedulingTests.swift` (add a suite) + +**Interfaces:** +- Produces: `final class DayStartStore { init(defaults: UserDefaults = AppGroup.defaults); func confirmedStart(for: UUID) -> Date?; func setConfirmedStart(_ : Date, for: UUID); func hasConfirmedStart(for: UUID, onDayContaining: Date, calendar: Calendar) -> Bool }` + +- [ ] **Step 1: Write the failing test** — append to `OpenAppLockTests/SchedulingTests.swift`: + +```swift +@MainActor +@Suite("Day-start store") +struct DayStartStoreTests { + private func makeStore() -> DayStartStore { + let name = "daystart-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return DayStartStore(defaults: defaults) + } + + @Test("Confirmed start round-trips and is day-scoped") + func roundTrip() { + let store = makeStore() + let id = UUID() + let monday = date(2025, 1, 6, 10, 0) + #expect(store.confirmedStart(for: id) == nil) + #expect(!store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + + store.setConfirmedStart(utc.startOfDay(for: monday), for: id) + #expect(store.confirmedStart(for: id) == utc.startOfDay(for: monday)) + #expect(store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + // A different day is not confirmed. + #expect(!store.hasConfirmedStart(for: id, onDayContaining: date(2025, 1, 7, 1, 0), calendar: utc)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** — Xcode MCP `RunSomeTests` for `OpenAppLockTests/DayStartStoreTests`. Expected: FAIL (`DayStartStore` undefined / won't compile). + +- [ ] **Step 3: Write minimal implementation** — create `Shared/DayStartStore.swift`: + +```swift +// +// DayStartStore.swift +// OpenAppLock +// + +import Foundation + +/// Per-rule "last confirmed daily-activity start", written when the monitor's +/// `intervalDidStart` fires (and defensively by the foreground enforcer). Lets +/// limit enforcement reject usage checkpoints that arrive before today's +/// interval boundary has been observed — i.e. yesterday's batched threshold +/// events flushed late across midnight. +final class DayStartStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + func confirmedStart(for ruleID: UUID) -> Date? { + defaults.object(forKey: key(ruleID)) as? Date + } + + func setConfirmedStart(_ dayStart: Date, for ruleID: UUID) { + defaults.set(dayStart, forKey: key(ruleID)) + } + + /// Whether the confirmed start equals the start of the day containing `date`. + func hasConfirmedStart( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> Bool { + confirmedStart(for: ruleID) == calendar.startOfDay(for: date) + } + + private func key(_ ruleID: UUID) -> String { + "dayStart/\(ruleID.uuidString)" + } +} +``` + +Then add `DayStartStore.swift` to the **OpenAppLock app** and **OpenAppLockMonitor** targets' membership (it is used by both). Mirror how `Shared/UsageLedger.swift` is referenced in `project.pbxproj` (add a `PBXFileReference` + `PBXBuildFile` entries in both targets' Sources phases). + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/DayStartStoreTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/DayStartStore.swift OpenAppLock.xcodeproj/project.pbxproj OpenAppLockTests/SchedulingTests.swift +git commit # message: "feat: add DayStartStore for confirmed daily-activity starts" + trailers +``` + +--- + +## Task A2: Record usage only for eligible rules (4a) + +**Files:** +- Modify: `Shared/LimitEnforcement.swift` (`handleUsageMinutes`, lines ~47–71) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`LimitEnforcementTests` suite) + +**Interfaces:** +- Consumes: existing `LimitEnforcement`, `MockShieldController`, `snapshot(...)` helper. +- Produces: no signature change. + +- [ ] **Step 1: Write the failing test** — in `LimitEnforcementTests`, first extend the `snapshot` helper to take days, then add a test. + +Change the helper signature from: +```swift + private func snapshot( + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil + ) -> RuleSnapshot { +``` +to add a `days` parameter: +```swift + private func snapshot( + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, + days: Set = Weekday.everyDay, pausedUntil: Date? = nil + ) -> RuleSnapshot { +``` +and use `dayNumbers: days.map(\.rawValue)` in the returned `RuleSnapshot` (replacing `Weekday.everyDay.map(\.rawValue)`). + +Add the test: +```swift + @Test("An ineligible rule does not accrue usage from a checkpoint") + func ineligibleRuleDoesNotAccrue() { + let (enforcement, _, ledger, store) = makeEnforcement() + // Weekday-only rule; a checkpoint arrives on a Saturday (not scheduled). + let snap = snapshot(kind: .timeLimit, days: Weekday.weekdays) + store.save([snap]) + let saturday = date(2025, 1, 11, 10, 0) // 2025-01-11 is a Saturday + + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: saturday, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: saturday, calendar: utc).minutesUsed == 0) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: FAIL — `ineligibleRuleDoesNotAccrue` records 20 because `recordMinutesUsed` runs before the eligibility guard. + +- [ ] **Step 3: Write minimal implementation** — in `Shared/LimitEnforcement.swift`, reorder `handleUsageMinutes` so the record happens after eligibility. Replace the body after the magnitude guard: + +```swift + let minutesSinceMidnight = Int( + now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) + guard minutes <= minutesSinceMidnight else { return } + + guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, + snapshot.kind == .timeLimit, + !snapshot.isPaused(at: now), + snapshot.isScheduledToday(at: now, calendar: calendar) + else { return } + ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) + let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) + if snapshot.limitReached(given: usage) { + shield(snapshot) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: PASS (all, including the pre-existing checkpoint tests). + +- [ ] **Step 5: Commit** + +```bash +git add Shared/LimitEnforcement.swift OpenAppLockTests/SchedulingTests.swift +git commit # "fix: record time-limit usage only for rules eligible today" + trailers +``` + +--- + +## Task A3: Confirmed day-start gate + zero-once (4b) + +**Files:** +- Modify: `Shared/LimitEnforcement.swift` (add `dayStarts` member; `handleDayStart`; gate in `handleUsageMinutes`) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`LimitEnforcementTests`) + +**Interfaces:** +- Consumes: `DayStartStore` (A1). +- Produces: `LimitEnforcement` gains member `var dayStarts = DayStartStore()`. + +- [ ] **Step 1: Write the failing tests.** + +First, update `makeEnforcement()` to inject an isolated `DayStartStore`: +```swift + private func makeEnforcement() -> (LimitEnforcement, MockShieldController, UsageLedger, RuleSnapshotStore) { + let shields = MockShieldController() + let ledger = UsageLedger(defaults: freshDefaults()) + let store = RuleSnapshotStore(defaults: freshDefaults()) + return ( + LimitEnforcement( + snapshots: store, ledger: ledger, shields: shields, + sessions: OpenSessionStore(defaults: freshDefaults()), + dayStarts: DayStartStore(defaults: freshDefaults())), + shields, ledger, store) + } +``` + +Next, the three existing checkpoint tests must establish a confirmed day-start first (the gate now requires it). Update them: + +In `usageCheckpointsShieldAtLimit`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) +``` +In `staleCrossMidnightCheckpointIgnored`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: earlyMorning, calendar: utc) +``` +In `freshCheckpointWithinElapsedHonoured`, insert after `store.save([snap])`: +```swift + enforcement.handleDayStart(ruleID: snap.id, now: quarterToOne, calendar: utc) +``` + +Then add new tests: +```swift + @Test("A checkpoint before a confirmed day-start is dropped") + func checkpointBeforeConfirmedStartDropped() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + // No handleDayStart → no confirmed start for today. + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + #expect(shields.shieldedRuleIDs.isEmpty) + } + + @Test("Day start zeroes today's time-limit ledger once, only on a transition") + func dayStartZeroesOnceOnTransition() { + let (enforcement, _, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit) + store.save([snap]) + // A stale value sitting in today's key (e.g. a pre-boundary write). + ledger.setUsage( + RuleUsage(minutesUsed: 45), for: snap.id, onDayContaining: monday, calendar: utc) + + // First day-start: transition → zeroed. + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + + // A legitimate accrual after the transition... + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + + // ...survives a spurious same-day re-fire (no second zero). + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: FAIL to compile (`dayStarts:` arg doesn't exist yet) and/or new tests fail. + +- [ ] **Step 3: Write minimal implementation** — in `Shared/LimitEnforcement.swift`: + +Add the member after `var sessions = OpenSessionStore()`: +```swift + /// Confirmed daily-activity starts, used to reject pre-boundary stale flushes. + var dayStarts = DayStartStore() +``` + +Replace `handleDayStart` with: +```swift + func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) { + guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, + !snapshot.isPaused(at: now) + else { return } + confirmDayStart(ruleID: ruleID, kind: snapshot.kind, now: now, calendar: calendar) + let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) + switch snapshot.kind { + case .schedule: + break + case .openLimit: + if snapshot.isScheduledToday(at: now, calendar: calendar) { + shield(snapshot) + } else { + shields.clearShield(ruleID: ruleID) + } + case .timeLimit: + if snapshot.limitReached(given: usage, at: now), + snapshot.isScheduledToday(at: now, calendar: calendar) { + shield(snapshot) + } else { + shields.clearShield(ruleID: ruleID) + } + } + } + + /// Records today as the confirmed interval start for `ruleID`. On a genuine + /// new-day transition for a time-limit rule, zeroes today's ledger once so a + /// stale pre-boundary checkpoint cannot survive; a spurious same-day re-fire + /// must not erase legitimate usage. + private func confirmDayStart( + ruleID: UUID, kind: RuleKind, now: Date, calendar: Calendar + ) { + let today = calendar.startOfDay(for: now) + guard dayStarts.confirmedStart(for: ruleID) != today else { return } + dayStarts.setConfirmedStart(today, for: ruleID) + if kind == .timeLimit { + ledger.setUsage(RuleUsage(), for: ruleID, onDayContaining: now, calendar: calendar) + } + } +``` +(Note: `limitReached(given:at:)` gets its `at:` parameter in Task B4; until then call it as `limitReached(given: usage)`. If executing strictly in order, write `snapshot.limitReached(given: usage)` here and add `at: now` in B4.) + +Add the gate to `handleUsageMinutes`, immediately after the magnitude guard: +```swift + guard minutes <= minutesSinceMidnight else { return } + // Reject events that arrive before today's interval boundary has been + // observed — yesterday's batched checkpoints flushed across midnight. + guard dayStarts.hasConfirmedStart(for: ruleID, onDayContaining: now, calendar: calendar) + else { return } +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/LimitEnforcementTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add Shared/LimitEnforcement.swift OpenAppLockTests/SchedulingTests.swift +git commit # "fix: gate time-limit usage on a confirmed day-start" + trailers +``` + +--- + +## Task A4: Foreground safety net (4c) + +**Files:** +- Modify: `OpenAppLock/Services/RuleEnforcer.swift` (init + `refresh`) +- Test: `OpenAppLockTests/RuleEnforcerTests.swift` + +**Interfaces:** +- Consumes: `DayStartStore` (A1). +- Produces: `RuleEnforcer.init(..., dayStarts: DayStartStore = DayStartStore())`. + +- [ ] **Step 1: Write the failing test** — add to `RuleEnforcerTests`: + +```swift + @Test("Refresh establishes today's confirmed day-start for a time-limit rule") + func refreshEstablishesConfirmedStart() { + let shields = MockShieldController() + let suite = "enforcer-daystart-\(UUID().uuidString)" + let dayStarts = DayStartStore(defaults: UserDefaults(suiteName: suite)!) + let enforcer = RuleEnforcer(shields: shields, dayStarts: dayStarts) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig()), days: Weekday.everyDay) + + #expect(dayStarts.confirmedStart(for: rule.id) == nil) + enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + #expect(dayStarts.confirmedStart(for: rule.id) == utc.startOfDay(for: mondayDuringWork)) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/RuleEnforcerTests`. Expected: FAIL to compile (`dayStarts:` arg doesn't exist). + +- [ ] **Step 3: Write minimal implementation** — in `OpenAppLock/Services/RuleEnforcer.swift`: + +Add a stored property and init parameter: +```swift + private let dayStarts: DayStartStore +``` +In `init`, add `dayStarts: DayStartStore = DayStartStore()` as the last parameter and assign `self.dayStarts = dayStarts`. + +In `refresh`, inside the `for rule in rules` loop, after the pause-expiry block and before computing `usage`: +```swift + // 4c safety net: a skipped monitor `intervalDidStart` would block + // usage recording all day; establish today's confirmed start from + // the foreground (no zeroing — preserve any legitimate accrual). + if rule.kind == .timeLimit, rule.isEnabled, + dayStarts.confirmedStart(for: rule.id) != calendar.startOfDay(for: now) { + dayStarts.setConfirmedStart(calendar.startOfDay(for: now), for: rule.id) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/RuleEnforcerTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Services/RuleEnforcer.swift OpenAppLockTests/RuleEnforcerTests.swift +git commit # "feat: establish confirmed day-start from the foreground enforcer" + trailers +``` + +--- + +## Task B1: Collapse the threshold chain to one block event (5a) + +**Files:** +- Modify: `Shared/MonitoringPlan.swift` (`minuteEvents(forLimit:)` → `blockEvent(forLimit:)`) +- Modify: `OpenAppLock/Services/RuleScheduler.swift` (call site, ~line 70) +- Test: `OpenAppLockTests/SchedulingTests.swift` (`MonitoringPlanTests`, `RuleSchedulerTests`) + +**Interfaces:** +- Produces: `static func blockEvent(forLimit limitMinutes: Int) -> [String: Int]` (one entry). + +- [ ] **Step 1: Write the failing tests.** + +Replace `MonitoringPlanTests.minuteEvents` with: +```swift + @Test("A time limit registers a single block event at the budget") + func blockEvent() { + let events = MonitoringPlan.blockEvent(forLimit: 45) + #expect(events.count == 1) + #expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45) + #expect( + MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 45)) == 45) + #expect(MonitoringPlan.minutes(fromEventName: "nope") == nil) + } +``` + +In `RuleSchedulerTests.startsMonitoring`, change: +```swift + #expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes) +``` +to: +```swift + #expect(monitor.startedEvents[name]?.count == 1) + #expect(monitor.startedEvents[name]?[MonitoringPlan.minuteEventName(for: rule.dailyLimitMinutes)] == rule.dailyLimitMinutes) +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/MonitoringPlanTests` and `OpenAppLockTests/RuleSchedulerTests`. Expected: FAIL (`blockEvent` undefined). + +- [ ] **Step 3: Write minimal implementation.** + +In `Shared/MonitoringPlan.swift`, replace `minuteEvents(forLimit:)` with: +```swift + /// The single cumulative-usage checkpoint for a time-limit rule: one event + /// at the budget, used by the monitor as the background block trigger. Live + /// sub-budget progress comes from the DeviceActivityReport extension, not a + /// per-minute chain (Screen Time batches sub-budget thresholds unreliably). + static func blockEvent(forLimit limitMinutes: Int) -> [String: Int] { + let minutes = max(1, limitMinutes) + return [minuteEventName(for: minutes): minutes] + } +``` + +In `OpenAppLock/Services/RuleScheduler.swift`, change: +```swift + let events = + rule.kind == .timeLimit + ? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes) + : [:] +``` +to: +```swift + let events = + rule.kind == .timeLimit + ? MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes) + : [:] +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/MonitoringPlanTests` and `OpenAppLockTests/RuleSchedulerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/MonitoringPlan.swift OpenAppLock/Services/RuleScheduler.swift OpenAppLockTests/SchedulingTests.swift +git commit # "feat: collapse time-limit threshold chain to a single block event" + trailers +``` + +--- + +## Task B2: Authoritative usage fields + effective resolver (5b) + +**Files:** +- Modify: `Shared/UsageLedger.swift` (`RuleUsage`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageLedgerTests`) + +**Interfaces:** +- Produces: `RuleUsage.authoritativeMinutesUsed: Int?`, `RuleUsage.authoritativeAsOf: Date?`, `static RuleUsage.authoritativeFreshness: TimeInterval`, `func effectiveMinutesUsed(asOf: Date, freshness: TimeInterval) -> Int`. + +- [ ] **Step 1: Write the failing tests** — add to `UsageLedgerTests`: + +```swift + @Test("Effective minutes prefer a fresh authoritative reading, else fall back") + func effectiveMinutes() { + let now = date(2025, 1, 6, 10, 0) + var usage = RuleUsage(minutesUsed: 12) + // No authoritative reading → threshold count. + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + // Fresh authoritative → wins. + usage.authoritativeMinutesUsed = 20 + usage.authoritativeAsOf = now.addingTimeInterval(-30) + #expect(usage.effectiveMinutesUsed(asOf: now) == 20) + // Stale authoritative → threshold fallback. + usage.authoritativeAsOf = now.addingTimeInterval(-600) + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + } + + @Test("Usage round-trips authoritative fields and decodes legacy blobs") + func authoritativeCodable() throws { + var usage = RuleUsage(minutesUsed: 5, opensUsed: 2) + usage.authoritativeMinutesUsed = 30 + usage.authoritativeAsOf = date(2025, 1, 6, 10, 0) + let data = try JSONEncoder().encode(usage) + #expect(try JSONDecoder().decode(RuleUsage.self, from: data) == usage) + + // A blob written before the authoritative fields existed still decodes. + let legacy = Data(#"{"minutesUsed":7,"opensUsed":1}"#.utf8) + let decoded = try JSONDecoder().decode(RuleUsage.self, from: legacy) + #expect(decoded.minutesUsed == 7) + #expect(decoded.authoritativeMinutesUsed == nil) + #expect(decoded.authoritativeAsOf == nil) + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: FAIL (no such members). + +- [ ] **Step 3: Write minimal implementation** — in `Shared/UsageLedger.swift`, replace the `RuleUsage` struct: + +```swift +struct RuleUsage: Codable, Equatable { + var minutesUsed = 0 + var opensUsed = 0 + /// The true daily total written by the DeviceActivityReport extension while + /// the app is foreground; preferred over `minutesUsed` when fresh. + var authoritativeMinutesUsed: Int? + /// When the authoritative total was computed. + var authoritativeAsOf: Date? + + /// How long an authoritative reading is trusted before falling back to the + /// threshold count. Tunable on device. + static let authoritativeFreshness: TimeInterval = 120 + + /// The daily minutes to use for display and the block decision: the report's + /// authoritative total when fresh, else the threshold count. + func effectiveMinutesUsed( + asOf now: Date, freshness: TimeInterval = RuleUsage.authoritativeFreshness + ) -> Int { + if let authoritative = authoritativeMinutesUsed, let asOf = authoritativeAsOf, + abs(now.timeIntervalSince(asOf)) <= freshness { + return authoritative + } + return minutesUsed + } +} +``` +(Optional properties get an implicit `nil` default, so the existing memberwise calls `RuleUsage(minutesUsed:)` / `RuleUsage(minutesUsed:opensUsed:)` keep compiling, and synthesized `Decodable` treats them as `decodeIfPresent` so legacy blobs decode.) + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/UsageLedger.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: add authoritative usage fields and effective-minutes resolver" + trailers +``` + +--- + +## Task B3: UsageLedger.recordAuthoritativeMinutes (5b) + +**Files:** +- Modify: `Shared/UsageLedger.swift` (add method) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageLedgerTests`) + +**Interfaces:** +- Produces: `func recordAuthoritativeMinutes(_ minutes: Int, for: UUID, onDayContaining: Date, asOf: Date, calendar: Calendar)`. + +- [ ] **Step 1: Write the failing test** — add to `UsageLedgerTests`: + +```swift + @Test("Authoritative minutes overwrite without disturbing the threshold count") + func recordAuthoritative() { + let ledger = makeLedger() + let id = UUID() + ledger.recordMinutesUsed(40, for: id, onDayContaining: monday, calendar: utc) + + ledger.recordAuthoritativeMinutes( + 12, for: id, onDayContaining: monday, asOf: monday, calendar: utc) + let read = ledger.usage(for: id, onDayContaining: monday, calendar: utc) + #expect(read.minutesUsed == 40) // threshold untouched + #expect(read.authoritativeMinutesUsed == 12) // authoritative recorded + #expect(read.authoritativeAsOf == monday) + // Effective prefers the (fresh) authoritative figure. + #expect(read.effectiveMinutesUsed(asOf: monday) == 12) + } +``` + +- [ ] **Step 2: Run test to verify it fails** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: FAIL (no such method). + +- [ ] **Step 3: Write minimal implementation** — in `Shared/UsageLedger.swift`, add to `UsageLedger` (after `recordMinutesUsed`): + +```swift + /// Records the report's authoritative daily total without disturbing the + /// monotonic threshold count. + func recordAuthoritativeMinutes( + _ minutes: Int, for ruleID: UUID, onDayContaining date: Date, asOf: Date, + calendar: Calendar = .current + ) { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.authoritativeMinutesUsed = minutes + usage.authoritativeAsOf = asOf + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + } +``` + +- [ ] **Step 4: Run test to verify it passes** — `RunSomeTests` for `OpenAppLockTests/UsageLedgerTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Shared/UsageLedger.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: record authoritative usage minutes in the ledger" + trailers +``` + +--- + +## Task B4: limitReached uses effective minutes (5c) + +**Files:** +- Modify: `OpenAppLock/Logic/RuleStatus.swift` (`BlockingRule.limitReached`, `status`) +- Modify: `Shared/RuleSnapshot.swift` (`RuleSnapshot.limitReached`) +- Modify: `Shared/LimitEnforcement.swift` (3 call sites pass `now`) +- Modify: `Shared/UninstallProtectionPolicy.swift` (1 call site passes `now`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageStatusTests`, `UsageEnforcementTests`) + +**Interfaces:** +- Produces: `limitReached(given: RuleUsage, at now: Date = .now) -> Bool` on both `BlockingRule` and `RuleSnapshot`. + +- [ ] **Step 1: Write the failing tests** — add to `UsageStatusTests`: + +```swift + @Test("A fresh authoritative reading below budget keeps a rule inactive") + func freshAuthoritativeBelowBudgetInactive() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) // threshold says spent (phantom) + usage.authoritativeMinutesUsed = 5 // report says 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect(!rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } + + @Test("A fresh authoritative reading at budget blocks even if threshold lags") + func freshAuthoritativeAtBudgetBlocks() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 10) + usage.authoritativeMinutesUsed = 45 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect( + rule.status(at: mondayMorning, calendar: utc, usage: usage) + == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("A stale authoritative reading falls back to the threshold count") + func staleAuthoritativeUsesThreshold() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-600) // stale + #expect(rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } +``` + +Add to `UsageEnforcementTests`: +```swift + @Test("A fresh authoritative reading below budget clears a phantom block") + func freshAuthoritativeClearsPhantomBlock() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) + var usage = RuleUsage(minutesUsed: 45) // threshold phantom + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + ledger.usageByRule[rule.id] = usage + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs.isEmpty) // authoritative wins → not blocked + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageStatusTests` and `OpenAppLockTests/UsageEnforcementTests`. Expected: FAIL (status uses raw `minutesUsed`). + +- [ ] **Step 3: Write minimal implementation.** + +In `OpenAppLock/Logic/RuleStatus.swift`, change `limitReached`: +```swift + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { + switch configuration { + case .schedule: false + case .timeLimit(let config): usage.effectiveMinutesUsed(asOf: now) >= config.dailyLimitMinutes + case .openLimit(let config): usage.opensUsed >= config.maxOpens + } + } +``` +In the same file, in `status(...)`, change the limit branch call from `limitReached(given: usage)` to `limitReached(given: usage, at: now)`. + +In `Shared/RuleSnapshot.swift`, change `limitReached`: +```swift + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { + switch kind { + case .schedule: false + case .timeLimit: usage.effectiveMinutesUsed(asOf: now) >= dailyLimitMinutes + case .openLimit: usage.opensUsed >= maxOpens + } + } +``` + +In `Shared/LimitEnforcement.swift`, pass `now` at the three `snapshot.limitReached(given: usage)` calls (in `handleDayStart`, `handleUsageMinutes`, `handleOpenRequest`): `snapshot.limitReached(given: usage, at: now)`. + +In `Shared/UninstallProtectionPolicy.swift` line ~61, change `snapshot.limitReached(given: usage)` to `snapshot.limitReached(given: usage, at: now)`. + +- [ ] **Step 4: Run the full suite to verify pass + no regressions** — `RunSomeTests` for `OpenAppLockTests/UsageStatusTests`, `OpenAppLockTests/UsageEnforcementTests`, `OpenAppLockTests/LimitEnforcementTests`, `OpenAppLockTests/UninstallProtectionEnforcerTests`. Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Logic/RuleStatus.swift Shared/RuleSnapshot.swift Shared/LimitEnforcement.swift Shared/UninstallProtectionPolicy.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: use effective (authoritative-aware) minutes in limitReached" + trailers +``` + +--- + +## Task B5: Usage display uses effective minutes (5c) + +**Files:** +- Modify: `OpenAppLock/Logic/UsageDisplay.swift` (`usagePhrase`) +- Modify: `OpenAppLock/Logic/RuleStatus.swift` (`rowContext` passes `now` to `usagePhrase`) +- Test: `OpenAppLockTests/UsageTests.swift` (`UsageDisplayTests`) + +**Interfaces:** +- Produces: `UsageDisplay.usagePhrase(for: BlockingRule, usage: RuleUsage, asOf now: Date) -> String`. + +- [ ] **Step 1: Write the failing tests.** + +Update the three direct callers in `UsageDisplayTests` to pass `asOf: now`: +```swift + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") +``` +(in `timeLimitStrings`), +```swift + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage, asOf: now) == "2 of 5 opens") +``` +(in `openLimitStrings`), and +```swift + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over, asOf: now) == "45m of 45m used") +``` +(in `overshootClamps`). + +Add a new test: +```swift + @Test("Usage phrase reflects a fresh authoritative reading") + func usagePhrasePrefersFreshAuthoritative() { + var usage = RuleUsage(minutesUsed: 5) + usage.authoritativeMinutesUsed = 18 + usage.authoritativeAsOf = now.addingTimeInterval(-10) + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") + } +``` + +- [ ] **Step 2: Run tests to verify they fail** — `RunSomeTests` for `OpenAppLockTests/UsageDisplayTests`. Expected: FAIL (signature mismatch). + +- [ ] **Step 3: Write minimal implementation.** + +In `OpenAppLock/Logic/UsageDisplay.swift`, change `usagePhrase`: +```swift + static func usagePhrase(for rule: BlockingRule, usage: RuleUsage, asOf now: Date) -> String { + switch rule.configuration { + case .schedule: + "" + case .timeLimit(let config): + "\(min(usage.effectiveMinutesUsed(asOf: now), config.dailyLimitMinutes))m of " + + "\(config.dailyLimitMinutes)m used" + case .openLimit(let config): + "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens" + } + } +``` + +In `OpenAppLock/Logic/RuleStatus.swift`, in `rowContext`, change: +```swift + ? UsageDisplay.usagePhrase(for: self, usage: usage) +``` +to: +```swift + ? UsageDisplay.usagePhrase(for: self, usage: usage, asOf: now) +``` + +- [ ] **Step 4: Run tests to verify they pass** — `RunSomeTests` for `OpenAppLockTests/UsageDisplayTests`. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add OpenAppLock/Logic/UsageDisplay.swift OpenAppLock/Logic/RuleStatus.swift OpenAppLockTests/UsageTests.swift +git commit # "feat: show effective (authoritative-aware) minutes in usage strings" + trailers +``` + +--- + +## Task B5.5: Full regression checkpoint + +- [ ] **Step 1: Run the whole unit suite** — Xcode MCP `RunAllTests` (or `RunSomeTests` for `OpenAppLockTests`). Expected: PASS for every suite. Fix any regression before proceeding to the device-only tasks. No commit (verification only). + +--- + +## Task B6: OpenAppLockReport extension target (pbxproj hand-edit) — device-only + +> Cannot be unit-tested. The gate is: the project opens, `plutil -lint` passes, and the app + all four extensions build via the Xcode MCP. + +**Files:** +- Create: `OpenAppLockReport/Info.plist` +- Create: `OpenAppLockReport/OpenAppLockReport.entitlements` +- Modify: `OpenAppLock.xcodeproj/project.pbxproj` + +- [ ] **Step 1: Create the Info.plist** — `OpenAppLockReport/Info.plist`: + +```xml + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.report-extension + + + +``` + +- [ ] **Step 2: Create the entitlements** — `OpenAppLockReport/OpenAppLockReport.entitlements` (identical to `OpenAppLockMonitor.entitlements`): + +```xml + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + +``` + +- [ ] **Step 3: Add the target to project.pbxproj** — read the file and replicate every `OpenAppLockMonitor` object for a new `OpenAppLockReport` target, with fresh unique 24-hex IDs (the existing extensions use a synthetic `E40000000000000000000001`–`0003` scheme for their build-configuration lists; continue with `…0004` for the report's list and unique hex for the rest). Specifically duplicate and adapt: + - the `PBXNativeTarget` (productType `com.apple.product-type.app-extension`), its three build phases (Sources, Frameworks, Resources), and its `buildConfigurationList`; + - the `XCBuildConfiguration` Debug + Release, setting `PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report`, `INFOPLIST_FILE = OpenAppLockReport/Info.plist`, `CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements`, same `IPHONEOS_DEPLOYMENT_TARGET`/team/`SWIFT_VERSION`/`GENERATE_INFOPLIST_FILE` settings as the monitor, and `INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport`; + - the `.appex` `PBXFileReference` (product) and the group entries for the `OpenAppLockReport/` folder + its files; + - a `PBXBuildFile` + `PBXTargetDependency` so the app target **embeds** the new `.appex` (add it to the app's existing "Embed App Extensions"/Copy Files (Plug-ins) phase and to `dependencies`). + - the **Sources** build phase must list the report's own Swift files (Task B7) **and** the Shared files they need: `AppGroup.swift`, `RuleKind.swift`, `Weekday.swift`, `RuleSchedule.swift`, `RuleConfiguration.swift`, `RuleSnapshot.swift`, `UsageLedger.swift`, `ShieldController.swift` (for `AppSelectionCodec`), `DeviceActivityReportContext.swift`. (Mirror the Shared files already in the monitor target, plus these.) + +- [ ] **Step 4: Lint the project file** — Run: `plutil -lint OpenAppLock.xcodeproj/project.pbxproj`. Expected: `OK`. If not, the edit is malformed — fix before building. + +- [ ] **Step 5: Build** — Xcode MCP `BuildProject` (simulator destination). Expected: build **fails** with "missing file/symbol" only if Task B7 files don't exist yet — that is acceptable here; the gate for this task is that the project parses and the new target appears. If the report Swift files already exist, expect a clean build. (Practical ordering: do B7's file creation, then return here to build green.) + +- [ ] **Step 6: Commit** + +```bash +git add OpenAppLockReport/Info.plist OpenAppLockReport/OpenAppLockReport.entitlements OpenAppLock.xcodeproj/project.pbxproj +git commit # "build: add OpenAppLockReport DeviceActivityReport extension target" + trailers +``` + +--- + +## Task B7: Report extension code + host report view (5d) — device-only + +> Cannot be unit-tested (the simulator delivers no DeviceActivity data and does not render report extensions). Gate: the app + all four extensions build via the Xcode MCP. + +**Files:** +- Create: `Shared/DeviceActivityReportContext.swift` +- Create: `OpenAppLockReport/OpenAppLockReport.swift` +- Create: `OpenAppLockReport/RuleUsageReport.swift` +- Create: `OpenAppLockReport/RuleUsageReportWriter.swift` +- Modify: `OpenAppLock/Views/MainView.swift` +- Modify: `OpenAppLock.xcodeproj/project.pbxproj` (add the three report Swift files + `DeviceActivityReportContext.swift` to the report target; add `DeviceActivityReportContext.swift` to the app target) + +- [ ] **Step 1: Shared context** — `Shared/DeviceActivityReportContext.swift`: + +```swift +// +// DeviceActivityReportContext.swift +// OpenAppLock +// + +import DeviceActivity + +extension DeviceActivityReport.Context { + /// The report scene that recomputes authoritative daily usage for limit rules. + static let ruleUsage = Self("Rule Usage") +} +``` + +- [ ] **Step 2: Writer** — `OpenAppLockReport/RuleUsageReportWriter.swift`: + +```swift +// +// RuleUsageReportWriter.swift +// OpenAppLockReport +// + +import DeviceActivity +import FamilyControls +import Foundation + +/// Sums each enabled time-limit rule's true daily usage from Screen Time's own +/// totals and records it as the authoritative figure in the shared ledger. +struct RuleUsageReportWriter { + func write(from data: DeviceActivityResults, now: Date = Date()) async { + let snapshots = RuleSnapshotStore().load() + .filter { $0.kind == .timeLimit && $0.isEnabled } + guard !snapshots.isEmpty else { return } + let selections = snapshots.map { ($0, AppSelectionCodec.decode($0.selectionData)) } + + var secondsByRule: [UUID: Double] = [:] + for await segment in data.flatMap({ $0.activitySegments }) { + for await category in segment.categories { + for await app in category.applications { + guard let token = app.application.token else { continue } + let seconds = app.totalActivityDuration + for (snap, selection) in selections + where selection.applicationTokens.contains(token) { + secondsByRule[snap.id, default: 0] += seconds + } + } + } + } + + let ledger = UsageLedger() + for (snap, _) in selections { + let minutes = Int((secondsByRule[snap.id] ?? 0) / 60) + ledger.recordAuthoritativeMinutes( + minutes, for: snap.id, onDayContaining: now, asOf: now) + } + } +} +``` +(Note: category/web-domain selections are not attributed yet — applications only; confirm coverage on device, see spec §9.) + +- [ ] **Step 3: Scene** — `OpenAppLockReport/RuleUsageReport.swift`: + +```swift +// +// RuleUsageReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +/// Recomputes authoritative daily usage for time-limit rules as a side effect of +/// rendering. The view is intentionally empty — the app consumes the ledger +/// write, not the view. Runs only while the host app foregrounds a +/// `DeviceActivityReport(.ruleUsage, …)`. +struct RuleUsageReport: DeviceActivityReportScene { + let context: DeviceActivityReport.Context = .ruleUsage + let content: (Int) -> EmptyView = { _ in EmptyView() } + + func makeConfiguration( + representing data: DeviceActivityResults + ) async -> Int { + await RuleUsageReportWriter().write(from: data) + return 0 + } +} +``` + +- [ ] **Step 4: Extension entry point** — `OpenAppLockReport/OpenAppLockReport.swift`: + +```swift +// +// OpenAppLockReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +@main +struct OpenAppLockReport: DeviceActivityReportExtension { + var body: some DeviceActivityReportScene { + RuleUsageReport() + } +} +``` + +- [ ] **Step 5: Host the report in MainView** — in `OpenAppLock/Views/MainView.swift`, add imports `DeviceActivity`, `FamilyControls`, `ManagedSettings`, then attach a hidden report as a background on `layout`: + +```swift + layout + .background(ruleUsageReport) + .task { + await enforcementLoop() + } +``` +and add: +```swift + /// An invisible DeviceActivityReport so the report extension recomputes + /// authoritative usage whenever the app is foreground; the app reads the + /// resulting ledger writes on its 30 s refresh loop. + private var ruleUsageReport: some View { + DeviceActivityReport(.ruleUsage, filter: usageFilter) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + private var usageFilter: DeviceActivityFilter { + let calendar = Calendar.current + let interval = DateInterval(start: calendar.startOfDay(for: .now), end: .now) + var apps: Set = [] + var categories: Set = [] + var webDomains: Set = [] + for rule in rules where rule.kind == .timeLimit && rule.isEnabled { + let selection = AppSelectionCodec.decode(rule.appList?.selectionData) + apps.formUnion(selection.applicationTokens) + categories.formUnion(selection.categoryTokens) + webDomains.formUnion(selection.webDomainTokens) + } + return DeviceActivityFilter( + segment: .daily(during: interval), + users: .all, + devices: .init([.iPhone, .iPad]), + applications: apps, + categories: categories, + webDomains: webDomains) + } +``` + +- [ ] **Step 6: Wire files into targets** — in `project.pbxproj`: add the three `OpenAppLockReport/*.swift` files and `Shared/DeviceActivityReportContext.swift` to the **OpenAppLockReport** target's Sources phase; add `Shared/DeviceActivityReportContext.swift` to the **OpenAppLock** app target's Sources phase. + +- [ ] **Step 7: Lint + build** — Run `plutil -lint OpenAppLock.xcodeproj/project.pbxproj` (expect `OK`), then Xcode MCP `BuildProject` (simulator). Expected: clean build of the app + all four extensions. Resolve any "missing symbol in OpenAppLockReport" by adding the named Shared file to the report target's Sources phase. + +- [ ] **Step 8: Commit** + +```bash +git add Shared/DeviceActivityReportContext.swift OpenAppLockReport/ OpenAppLock/Views/MainView.swift OpenAppLock.xcodeproj/project.pbxproj +git commit # "feat: compute authoritative time-limit usage via DeviceActivityReport" + trailers +``` + +--- + +## Task C1: Update docs + memory + +**Files:** +- Modify: `Docs/AGENT_RULES_FEATURE_SPEC.md` (§5.5 Reliability posture) +- Modify: `AGENTS.md` (Known gaps / next steps) +- Modify: `~/.claude/projects/-Users-bchendev-Developer-OpenAppLock/memory/openapplock-issue2-usage-counter.md` (+ MEMORY.md pointer if status changes) + +- [ ] **Step 1: Update the feature spec §5.5** — describe the collapsed single block event, the confirmed-day-start gate, and the DeviceActivityReport authoritative reconciliation (foreground-only); note the residual background-false-block-until-foreground limit. Read the section first and edit in place to stay accurate. + +- [ ] **Step 2: Update AGENTS.md "Known gaps / next steps"** — replace the "stalls at ~14/15m" framing with the new design and the remaining device-verification items (report attribution for categories/web; 4b under-blocking if `intervalDidStart` is skipped; freshness-window tuning). Reference `Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md`. + +- [ ] **Step 3: Update the issue-2 memory** — note the hardening landed on `feat/time-limit-counting-hardening` (Part A unit-tested; Part B report extension pending on-device verification), so the file reflects current state. + +- [ ] **Step 4: Commit** (repo docs only; the memory file lives outside the repo and is saved separately) + +```bash +git add Docs/AGENT_RULES_FEATURE_SPEC.md AGENTS.md +git commit # "docs: record time-limit hardening in the feature spec and known gaps" + trailers +``` + +--- + +## Self-Review + +- **Spec coverage:** §4a→A2, §4b→A3, §4c→A4, §4d→documented in C1; §5a→B1, §5b→B2+B3, §5c→B4+B5, §5d→B6+B7; §7 test matrix→tasks' TDD steps; §8 sequencing→task order; §9 risks→B6/B7 gates + C1; §10 checklist→C1 / on-device follow-up. `DayStartStore` (§6) → A1. +- **Placeholder scan:** none — every code/test step contains full code; device-only tasks specify exact files, plist/entitlements contents, the pbxproj procedure, and a build gate. +- **Type consistency:** `DayStartStore` API (A1) matches A3/A4 use; `effectiveMinutesUsed(asOf:freshness:)` (B2) matches B4/B5; `recordAuthoritativeMinutes(_:for:onDayContaining:asOf:calendar:)` (B3) matches B7 writer; `blockEvent(forLimit:)` (B1) matches RuleScheduler; `limitReached(given:at:)` (B4) matches all four call sites; `.ruleUsage` context (B7) shared by extension + MainView. diff --git a/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md b/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md new file mode 100644 index 0000000..330f061 --- /dev/null +++ b/Docs/Agents/Specs/TIME_LIMIT_COUNTING_HARDENING.md @@ -0,0 +1,303 @@ +# Time-Limit Counting Hardening + +Design spec for making time-limit enforcement robust against unreliable Screen +Time threshold events. Agent-managed (lives under `Docs/Agents/`). The behavior +source of truth is the doc comments on the owning source files (indexed by the +"Rules feature map" in `AGENTS.md`); this spec is the design rationale behind +those changes. + +## 1. Problem + +Time-limit rules count usage by registering a chain of cumulative +`DeviceActivityEvent` thresholds (`minutes-1 … minutes-N`) over the rule's app +selection on an always-on, midnight-to-midnight repeating activity +(`MonitoringPlan.minuteEvents(forLimit:)`, `RuleScheduler.sync`). When a +threshold is crossed the monitor extension records the minute count and shields +at the budget (`LimitEnforcement.handleUsageMinutes`). + +`DeviceActivityEvent` thresholds are **not** a real-time, ordered, once-each +stream. iOS coalesces closely-spaced thresholds and flushes them when it next +wakes the monitor — often minutes to hours late, sometimes triggered by an +unrelated wake. Because the daily activity is `repeats: true`, every day reuses +the same event names, and a callback carries `minutes-k` but **no date**. So an +event Screen Time held overnight and flushed after midnight is +indistinguishable, by name, from a fresh one. (Corroborated by Apple Developer +Forums threads on `eventDidReachThreshold` batching/overcounting, and by this +project's device-research notes: the Usage counter "stalls at ~14/15m".) + +The existing mitigation is a magnitude guard +(`LimitEnforcement.handleUsageMinutes`): + +```swift +let minutesSinceMidnight = Int(now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) +guard minutes <= minutesSinceMidnight else { return } +``` + +It rejects a stale checkpoint only when its threshold exceeds today's elapsed +minutes — i.e. only in roughly the first `budget` minutes after midnight. Two +failure modes survive it: + +- **Scenario A — under-count.** Yesterday's modest usage (say 20m) flushes + mid-morning (elapsed = 480m). Every `minutes-1 … minutes-20` event passes the + guard and corrupts today's ledger with phantom usage, silently shrinking + today's real budget. +- **Scenario B — false block.** Yesterday maxed the budget; the `minutes-` + event flushes the next morning after `intervalDidStart` already cleared the + shield. `budget ≤ elapsed` passes the guard, the ledger records the full + budget, and apps the user never opened today are re-blocked all day. General + condition: `usedYesterday ≥ budget ∧ elapsedToday ≥ budget`. + +Aggravating: `recordMinutesUsed` runs **before** the +`isEnabled / kind / !isPaused / isScheduledToday` eligibility guards, so a stale +event corrupts today's ledger even for a rule that cannot be active today, and +the foreground `RuleEnforcer.refresh` later reads that corrupted value. + +## 2. Goals / non-goals + +**Goals** + +- Eliminate, or correct within one foreground refresh, the phantom blocks and + under-counts caused by cross-midnight stale threshold flushes. +- Provide an authoritative daily usage total for display and for the foreground + block decision, fixing the "stalls at ~14/15m" lag. +- Keep all reachable logic unit-testable from the app target; keep the + device-only surface (a new extension) as thin as possible. + +**Non-goals** + +- Fully eliminating background false blocks while the app is never opened. That + needs per-day event names (the previously-discussed "Option 2"), which we are + not doing here. With this design a residual background false block is + *corrected on the next foreground refresh*, not prevented in pure background. +- Changing time-limit user-facing semantics (budget, reset at next midnight, + Hard Mode, soft unblock). + +## 3. Architecture + +Two complementary parts: + +- **Part A (Option 3) — background hardening.** Shrinks the surface that can + corrupt the threshold-derived ledger at the source. Fully unit-tested. Ships + first and stands alone. +- **Part B (Option 1) — foreground authoritative reconciliation.** A + `DeviceActivityReport` extension computes the true daily total while the app + is foreground and writes it to the app group; the app prefers that + authoritative total for display and for the foreground block decision, + overriding any residual threshold corruption. + +`DeviceActivityReport` is a SwiftUI view whose data is computed in a separate +extension process **only while the view is rendered in the foreground app**. It +cannot feed the background monitor's block decision. Therefore Part A governs +the background; Part B governs the foreground and reconciles. A false block is +prevented in the common case (A) or cleared the moment the app is opened (B). + +## 4. Part A — background hardening + +### 4a. Record only for eligible rules + +In `LimitEnforcement.handleUsageMinutes`, move `ledger.recordMinutesUsed(...)` +**below** the eligibility guards (`isEnabled`, `kind == .timeLimit`, +`!isPaused`, `isScheduledToday`). A rule that cannot be active today no longer +accrues phantom usage today. (The daily activity runs every day regardless of +the rule's selected days, so today's events still arrive on an unscheduled day; +we simply stop recording them.) + +### 4b. Confirmed day-start gate + +Add `DayStartStore` in the app group: `ruleID → confirmed day-start Date` +(stored in `AppGroup.defaults`, same pattern as `RuleSnapshotStore`). + +- `handleDayStart` (fired by `intervalDidStart` for the daily activity): on a + genuine new-day transition only — `confirmedStart(ruleID) != startOfDay(now)` + — record `startOfDay(now)` as the confirmed start **and zero today's ledger + once**. Zeroing only on the transition is safe against a spurious mid-day + `intervalDidStart` re-fire (which would otherwise erase legitimate usage). +- `handleUsageMinutes`: after the magnitude guard, drop the event unless + `confirmedStart(ruleID) == startOfDay(now)`. This closes the *pre-boundary* + race — a stale flush that lands in the early morning before today's + `intervalDidStart` fires, in the window where the magnitude guard alone would + let it through (e.g. `intervalDidStart` delayed to 00:50, a `minutes-45` + residual arrives at 00:46: magnitude `45 ≤ 46` passes, but no confirmed start + for today exists yet → dropped). + +### 4c. Foreground safety net + +`RuleEnforcer.refresh` (foreground, runs on launch / rule change / every 30s) +**establishes a confirmed start for today if one is missing**, without zeroing +(to preserve any legitimate accrual). This bounds 4b's failure mode: if the +monitor's `intervalDidStart` is skipped for a day, the gate would otherwise +block all usage recording for that day; the safety net self-heals it the next +time the app is foregrounded. + +### 4d. Limits of Part A (documented, not fixed here) + +4b adds day-attribution only for the *pre-boundary* race. The *post-boundary* +Scenario B (interval fires, then a stale `minutes-` flush arrives) still +passes the magnitude guard and the confirmed-start gate, so a background false +block can still occur — it is cleared by Part B on the next foreground refresh. +4b also trades a small under-blocking risk (skipped `intervalDidStart` → no +recording until the app is next opened) for the under-count/false-block fix; +acceptable because the app is the source of truth whenever it runs, and Part B +re-derives the true total on foreground. Both are device-verification items. + +## 5. Part B — foreground authoritative reconciliation + +### 5a. Collapse the event chain + +`MonitoringPlan` returns a **single** `minutes-` block event for a time +limit instead of `minutes-1 … minutes-N`. Rename `minuteEvents(forLimit:)` → +`blockEvent(forLimit:)` (one-entry dictionary), keeping `minuteEventName` / +`minutes(fromEventName:)` for the name round-trip. `RuleScheduler.sync` calls +the new function. Background still blocks at the budget via that one event; the +cross-midnight stale surface shrinks to its minimum (only one event can ever +mis-fire). Live sub-budget progress now comes from the report (5c), not from the +per-minute chain — acceptable because the limited apps only accrue time while +OpenAppLock is *not* foreground, so "live" already meant "accurate as of when +you open the app." + +### 5b. Authoritative usage in the ledger + +Extend `RuleUsage` (tolerant `Codable`, optional fields so old blobs decode): + +```swift +var authoritativeMinutesUsed: Int? // true daily total from the report +var authoritativeAsOf: Date? // when the report computed it + +static let authoritativeFreshness: TimeInterval = 120 // tunable on device + +func effectiveMinutesUsed(asOf now: Date, + freshness: TimeInterval = RuleUsage.authoritativeFreshness) -> Int { + if let a = authoritativeMinutesUsed, let at = authoritativeAsOf, + abs(now.timeIntervalSince(at)) <= freshness { return a } + return minutesUsed +} +``` + +Add `UsageLedger.recordAuthoritativeMinutes(_:for:onDayContaining:asOf:)` (sets +the authoritative fields without disturbing the monotonic `minutesUsed`). + +One resolver serves both contexts correctly: + +- **Foreground:** the report keeps `authoritativeAsOf` fresh → authoritative + wins → fixes display lag and overrides residual threshold corruption. +- **Background:** authoritative is stale → falls back to `minutesUsed` (the + collapsed block event) → unchanged background behavior. + +### 5c. Consume effective minutes + +`limitReached` gains a `now` parameter and uses `effectiveMinutesUsed(asOf: now)` +in the time-limit branch (both `BlockingRule.limitReached` and +`RuleSnapshot.limitReached`; schedule/open-limit branches unchanged). The four +direct call sites already have `now` in scope: `BlockingRule.status` +(`RuleStatus.swift`), the three `LimitEnforcement` handlers, and +`UninstallProtectionPolicy.isActive`. `RulePolicy.shouldDenyAppRemoval` needs no +change — it routes through `rule.status(at:…)`, which already passes `now`. +`UsageDisplay.usagePhrase` gains a `now` parameter to compute effective minutes, +supplied by its only caller `BlockingRule.rowContext` (which already takes +`relativeTo now`). No new branch is needed in `RuleEnforcer`: once status uses +effective minutes, a fresh authoritative total below budget makes a rule's +status non-active, so the existing `clearShields(except:)` clears the phantom +shield automatically. + +### 5d. Report extension (`OpenAppLockReport`, device-only) + +New app-extension target added by mirroring the three existing extensions in +`project.pbxproj` (same app group + Family Controls entitlement; bundle id +`dev.bchen.OpenAppLock.Report`). Contents: + +- A `DeviceActivityReport.Context` value `.ruleUsage` and a + `DeviceActivityReportScene` whose `makeConfiguration(representing:)` reads the + rule snapshots, and for each enabled time-limit rule sums + `totalActivityDuration` over that rule's selection for the current day, then + calls `recordAuthoritativeMinutes`. The rendered view is empty/zero-size — we + use the scene only for its side effect. +- Attribution: the host filter covers the union of all time-limit rules' + selections over a `.daily` segment; the scene iterates segments → categories → + applications and, per rule, sums durations whose app/category token is in that + rule's selection. Overlapping rules each sum their own selection (independent + budgets). + +Host wiring: `MainView` embeds an invisible +`DeviceActivityReport(.ruleUsage, filter:)` so it renders whenever the app is +foreground; the filter is rebuilt from the current rules. The existing 30s +`RuleEnforcer.refresh` loop picks up the freshly-written authoritative totals +(snappier app-group key observation is possible later but not required for +correctness). + +## 6. Data model / API changes + +- `RuleUsage`: `+ authoritativeMinutesUsed: Int?`, `+ authoritativeAsOf: Date?`, + `+ effectiveMinutesUsed(asOf:freshness:)`, `+ static authoritativeFreshness`. +- `UsageLedger`: `+ recordAuthoritativeMinutes(_:for:onDayContaining:asOf:)`; + add `MockUsageLedger` seeding for the new fields. +- `MonitoringPlan`: `minuteEvents(forLimit:)` → `blockEvent(forLimit:)`. +- `LimitEnforcement`: reordered `handleUsageMinutes`; confirmed-start gate; + `handleDayStart` records confirmed start + zeroes once; new `DayStartStore` + dependency (injectable, app-group-backed, with a mock/fresh-defaults variant + for tests). +- `DayStartStore`: new file in `Shared/`. +- `limitReached(given:at:)` on `BlockingRule` and `RuleSnapshot`; the four + direct call sites updated (`RulePolicy` unchanged — routes via `status`). +- `UsageDisplay.usagePhrase(...,asOf:)` + `BlockingRule.rowContext` passes `now`. +- `RuleEnforcer.refresh`: confirmed-start safety net. +- New target `OpenAppLockReport` + host `DeviceActivityReport` view in `MainView`. + +## 7. Testing strategy (red/green TDD) + +Each change starts with a failing test (Swift Testing, mirroring +`SchedulingTests` patterns: `freshDefaults()`, `MockShieldController`, `date()`, +`utc`). + +Unit-tested (must be red first, then green): + +- **4a** — a disabled / paused / not-scheduled-today rule does not accrue + minutes from an in-range event (`LimitEnforcementTests`). +- **4b** — an in-range event before a confirmed day-start is dropped; after a + confirmed start it records; `handleDayStart` zeroes only on the day transition + and not on a same-day re-fire (`LimitEnforcementTests`, `DayStartStore` test). +- **4c** — `RuleEnforcer.refresh` establishes a confirmed start for today when + missing, without zeroing existing usage (`RuleEnforcerTests`). +- **5a** — `blockEvent(forLimit:)` returns one entry; `RuleScheduler` registers + one event; update the two affected `SchedulingTests`. +- **5b** — `effectiveMinutesUsed` prefers fresh authoritative, falls back when + stale; `RuleUsage` Codable round-trips the new fields and decodes legacy + blobs; `recordAuthoritativeMinutes` round-trips (`UsageTests`). +- **5c** — `limitReached(at:)` uses fresh authoritative; a fresh authoritative + below budget makes status non-active and clears the shield via + `RuleEnforcer.refresh` (`RuleStatusTests`, `RuleEnforcerTests`); `UsageDisplay` + shows the effective figure. + +Device-only (written + build-verified, **not** unit-tested; deferred +verification): the `OpenAppLockReport` scene + token-matching aggregation, the +invisible host report view, and the `project.pbxproj` target. The simulator +delivers no DeviceActivity data and does not render custom report extensions. + +## 8. Sequencing + +1. Part A (4a, 4b, 4c) — self-contained, shippable. +2. Part B shared logic (5a, 5b, 5c) — TDD. +3. Part B extension + pbxproj + host view (5d) — build-verified. +4. Update the owning source doc comments and AGENTS.md ("Rules feature map" + + "Known gaps"), and the `openapplock-issue2-usage-counter` memory. + +Build and test via the Xcode MCP on the simulator (no raw `xcodebuild`). + +## 9. Risks + +- **pbxproj hand-edit** can corrupt the project; verify the project opens and + builds after the edit, before writing extension code. +- **Freshness window** (120s) is a guess; tune on device so foreground stays + fresh across the 30s refresh cadence without trusting a stale reading. +- **4b under-blocking** if `intervalDidStart` is skipped all day and the app is + never opened (bounded by 4c + Part B). Device-verification item. +- **Report attribution** for category/web selections is unverified on device; + start with application tokens and confirm category coverage on hardware. + +## 10. On-device verification checklist (deferred) + +- Time-limit usage accrues in the Usage section and reads the true total on app + open (no "stalls at 14/15m"). +- Blocks at the budget; a maxed-out day does **not** re-block unused apps the + next morning (or clears within one foreground refresh if it does). +- A modest prior day does not shrink today's budget (no Scenario A under-count). +- Collapsed single event still blocks in pure background. diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index abefc5c..7f77c6a 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* OpenAppLockMonitor.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* OpenAppLockShieldConfig.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F10000000000000000000004 /* OpenAppLockReport.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B10000000000000000000004 /* OpenAppLockReport.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +49,13 @@ remoteGlobalIDString = C10000000000000000000003; remoteInfo = OpenAppLockShieldAction; }; + E20000000000000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 20A7EDC62E47B7CD0097608D /* Project object */; + proxyType = 1; + remoteGlobalIDString = C10000000000000000000004; + remoteInfo = OpenAppLockReport; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -60,6 +68,7 @@ F10000000000000000000001 /* OpenAppLockMonitor.appex in Embed Foundation Extensions */, F10000000000000000000002 /* OpenAppLockShieldConfig.appex in Embed Foundation Extensions */, F10000000000000000000003 /* OpenAppLockShieldAction.appex in Embed Foundation Extensions */, + F10000000000000000000004 /* OpenAppLockReport.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -73,6 +82,7 @@ B10000000000000000000001 /* OpenAppLockMonitor.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockMonitor.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B10000000000000000000002 /* OpenAppLockShieldConfig.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldConfig.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B10000000000000000000003 /* OpenAppLockShieldAction.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockShieldAction.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B10000000000000000000004 /* OpenAppLockReport.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenAppLockReport.appex; sourceTree = BUILT_PRODUCTS_DIR; }; E60000000000000000000001 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; E60000000000000000000002 /* CI.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = CI.xcconfig; sourceTree = ""; }; E60000000000000000000003 /* DeveloperSettings.sample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DeveloperSettings.sample.xcconfig; sourceTree = ""; }; @@ -107,6 +117,13 @@ ); target = C10000000000000000000003 /* OpenAppLockShieldAction */; }; + AB00000000000000000000B4 /* Exceptions for "OpenAppLockReport" folder in "OpenAppLockReport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C10000000000000000000004 /* OpenAppLockReport */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -157,6 +174,14 @@ path = OpenAppLockShieldAction; sourceTree = ""; }; + AA00000000000000000000A5 /* OpenAppLockReport */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AB00000000000000000000B4 /* Exceptions for "OpenAppLockReport" folder in "OpenAppLockReport" target */, + ); + path = OpenAppLockReport; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -202,6 +227,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D12000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -213,6 +245,7 @@ AA00000000000000000000A2 /* OpenAppLockMonitor */, AA00000000000000000000A3 /* OpenAppLockShieldConfig */, AA00000000000000000000A4 /* OpenAppLockShieldAction */, + AA00000000000000000000A5 /* OpenAppLockReport */, 20A7EDE02E47B7CF0097608D /* OpenAppLockTests */, 20A7EDEA2E47B7CF0097608D /* OpenAppLockUITests */, E60000000000000000000010 /* Config */, @@ -229,6 +262,7 @@ B10000000000000000000001 /* OpenAppLockMonitor.appex */, B10000000000000000000002 /* OpenAppLockShieldConfig.appex */, B10000000000000000000003 /* OpenAppLockShieldAction.appex */, + B10000000000000000000004 /* OpenAppLockReport.appex */, ); name = Products; sourceTree = ""; @@ -261,6 +295,7 @@ E30000000000000000000001 /* PBXTargetDependency */, E30000000000000000000002 /* PBXTargetDependency */, E30000000000000000000003 /* PBXTargetDependency */, + E30000000000000000000004 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 20A7EDD02E47B7CD0097608D /* OpenAppLock */, @@ -388,6 +423,29 @@ productReference = B10000000000000000000003 /* OpenAppLockShieldAction.appex */; productType = "com.apple.product-type.app-extension"; }; + C10000000000000000000004 /* OpenAppLockReport */ = { + isa = PBXNativeTarget; + buildConfigurationList = E40000000000000000000004 /* Build configuration list for PBXNativeTarget "OpenAppLockReport" */; + buildPhases = ( + D11000000000000000000004 /* Sources */, + D12000000000000000000004 /* Frameworks */, + D13000000000000000000004 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA00000000000000000000A1 /* Shared */, + AA00000000000000000000A5 /* OpenAppLockReport */, + ); + name = OpenAppLockReport; + packageProductDependencies = ( + ); + productName = OpenAppLockReport; + productReference = B10000000000000000000004 /* OpenAppLockReport.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -431,6 +489,7 @@ C10000000000000000000001 /* OpenAppLockMonitor */, C10000000000000000000002 /* OpenAppLockShieldConfig */, C10000000000000000000003 /* OpenAppLockShieldAction */, + C10000000000000000000004 /* OpenAppLockReport */, ); }; /* End PBXProject section */ @@ -478,6 +537,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D13000000000000000000004 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -523,6 +589,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D11000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -551,6 +624,11 @@ target = C10000000000000000000003 /* OpenAppLockShieldAction */; targetProxy = E20000000000000000000003 /* PBXContainerItemProxy */; }; + E30000000000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C10000000000000000000004 /* OpenAppLockReport */; + targetProxy = E20000000000000000000004 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1012,6 +1090,58 @@ }; name = Release; }; + E50000000000000000000041 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockReport/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E50000000000000000000042 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OpenAppLockReport/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.bchen.OpenAppLock.Report; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1078,6 +1208,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E40000000000000000000004 /* Build configuration list for PBXNativeTarget "OpenAppLockReport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E50000000000000000000041 /* Debug */, + E50000000000000000000042 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 20A7EDC62E47B7CD0097608D /* Project object */; diff --git a/OpenAppLock/Logic/RuleStatus.swift b/OpenAppLock/Logic/RuleStatus.swift index 5b503b1..2d81b61 100644 --- a/OpenAppLock/Logic/RuleStatus.swift +++ b/OpenAppLock/Logic/RuleStatus.swift @@ -57,7 +57,7 @@ extension BlockingRule { guard kind == .schedule else { if let usage, isScheduledToday(at: now, calendar: calendar), - limitReached(given: usage), + limitReached(given: usage, at: now), let midnight = calendar.nextMidnight(after: now) { if let pausedUntil, pausedUntil > now { return .paused(until: min(pausedUntil, midnight)) @@ -102,9 +102,9 @@ extension BlockingRule { case .disabled, .dormant, .paused: return status.label(relativeTo: now) case .active, .upcoming: - let usedToday = usage.minutesUsed > 0 || usage.opensUsed > 0 + let usedToday = usage.effectiveMinutesUsed(asOf: now) > 0 || usage.opensUsed > 0 return usedToday - ? UsageDisplay.usagePhrase(for: self, usage: usage) + ? UsageDisplay.usagePhrase(for: self, usage: usage, asOf: now) : UsageDisplay.budgetPhrase(for: self) } } @@ -120,10 +120,10 @@ extension BlockingRule { /// Whether the given usage exhausts this rule's daily budget. /// Always false for schedule rules — they block by the clock. - func limitReached(given usage: RuleUsage) -> Bool { + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { switch configuration { case .schedule: false - case .timeLimit(let config): usage.minutesUsed >= config.dailyLimitMinutes + case .timeLimit(let config): usage.effectiveMinutesUsed(asOf: now) >= config.dailyLimitMinutes case .openLimit(let config): usage.opensUsed >= config.maxOpens } } diff --git a/OpenAppLock/Logic/UsageDisplay.swift b/OpenAppLock/Logic/UsageDisplay.swift index 7b311e4..8189473 100644 --- a/OpenAppLock/Logic/UsageDisplay.swift +++ b/OpenAppLock/Logic/UsageDisplay.swift @@ -20,12 +20,12 @@ enum UsageDisplay { /// "18m of 45m used" / "2 of 5 opens". Empty for schedule rules, which have /// no usage budget. ("today" is implied — usage always covers the current day.) - static func usagePhrase(for rule: BlockingRule, usage: RuleUsage) -> String { + static func usagePhrase(for rule: BlockingRule, usage: RuleUsage, asOf now: Date) -> String { switch rule.configuration { case .schedule: "" case .timeLimit(let config): - "\(min(usage.minutesUsed, config.dailyLimitMinutes))m of " + "\(min(usage.effectiveMinutesUsed(asOf: now), config.dailyLimitMinutes))m of " + "\(config.dailyLimitMinutes)m used" case .openLimit(let config): "\(min(usage.opensUsed, config.maxOpens)) of \(config.maxOpens) opens" diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index bcfe75a..bce39f3 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -30,18 +30,23 @@ final class RuleEnforcer { /// App-wide settings (currently just Uninstall Protection) consulted on /// every refresh. private let settings: any AppSettingsReading + /// Confirmed daily-activity starts; the foreground establishes today's start + /// so a skipped monitor callback can't block usage recording all day. + private let dayStarts: DayStartStore init( shields: ShieldApplying, usage: UsageReading = UsageLedger(), scheduler: RuleScheduler? = nil, openSessions: OpenSessionReading = OpenSessionStore(), - settings: any AppSettingsReading = AppSettingsStore() + settings: any AppSettingsReading = AppSettingsStore(), + dayStarts: DayStartStore = DayStartStore() ) { self.shields = shields self.usageReader = usage self.scheduler = scheduler self.openSessions = openSessions self.settings = settings + self.dayStarts = dayStarts } /// The day's usage for a rule (nil for schedule rules, which don't track). @@ -76,6 +81,13 @@ final class RuleEnforcer { if let pausedUntil = rule.pausedUntil, pausedUntil <= now { rule.pausedUntil = nil } + // 4c safety net: a skipped monitor `intervalDidStart` would block + // usage recording all day; establish today's confirmed start from the + // foreground (no zeroing — preserve any legitimate accrual). + if rule.kind == .timeLimit, rule.isEnabled, + dayStarts.confirmedStart(for: rule.id) != calendar.startOfDay(for: now) { + dayStarts.setConfirmedStart(calendar.startOfDay(for: now), for: rule.id) + } let usage = usage(for: rule, at: now, calendar: calendar) let isBlocking = rule.status(at: now, calendar: calendar, usage: usage).isActive if isBlocking { blocking.insert(rule.id) } diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index 9c76d94..68fe7f2 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -67,7 +67,7 @@ final class RuleScheduler { desiredNames.insert(name) let events = rule.kind == .timeLimit - ? MonitoringPlan.minuteEvents(forLimit: rule.dailyLimitMinutes) + ? MonitoringPlan.blockEvent(forLimit: rule.dailyLimitMinutes) : [:] let fingerprint = "\(rule.kindRaw)|\(rule.dailyLimitMinutes)|" + Self.selectionFingerprint(selectionData) diff --git a/OpenAppLock/Views/MainView.swift b/OpenAppLock/Views/MainView.swift index 36afe01..7b57847 100644 --- a/OpenAppLock/Views/MainView.swift +++ b/OpenAppLock/Views/MainView.swift @@ -3,6 +3,9 @@ // OpenAppLock // +import DeviceActivity +import FamilyControls +import ManagedSettings import SwiftData import SwiftUI @@ -24,6 +27,7 @@ struct MainView: View { var body: some View { layout + .background(ruleUsageReport) .task { await enforcementLoop() } @@ -45,6 +49,41 @@ struct MainView: View { } } + // MARK: - Authoritative usage report + + /// An invisible `DeviceActivityReport` so the report extension recomputes + /// each time-limit rule's true daily usage whenever the app is foreground; + /// the 30 s refresh loop then reads the authoritative totals it writes. + private var ruleUsageReport: some View { + DeviceActivityReport(.ruleUsage, filter: usageFilter) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + /// Today's `.daily` filter over the union of all enabled time-limit rules' + /// selections — the data the report scene attributes back to each rule. + private var usageFilter: DeviceActivityFilter { + let calendar = Calendar.current + let interval = DateInterval(start: calendar.startOfDay(for: .now), end: .now) + var applications: Set = [] + var categories: Set = [] + var webDomains: Set = [] + for rule in rules where rule.kind == .timeLimit && rule.isEnabled { + let selection = AppSelectionCodec.decode(rule.appList?.selectionData) + applications.formUnion(selection.applicationTokens) + categories.formUnion(selection.categoryTokens) + webDomains.formUnion(selection.webDomainTokens) + } + return DeviceActivityFilter( + segment: .daily(during: interval), + users: .all, + devices: .init([.iPhone, .iPad]), + applications: applications, + categories: categories, + webDomains: webDomains) + } + // MARK: - Enforcement /// Changes whenever any rule's blocking-relevant state changes. diff --git a/OpenAppLockReport/Info.plist b/OpenAppLockReport/Info.plist new file mode 100644 index 0000000..afa8fd2 --- /dev/null +++ b/OpenAppLockReport/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.deviceactivity.report-extension + + + diff --git a/OpenAppLockReport/OpenAppLockReport.entitlements b/OpenAppLockReport/OpenAppLockReport.entitlements new file mode 100644 index 0000000..9038cbd --- /dev/null +++ b/OpenAppLockReport/OpenAppLockReport.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.dev.bchen.OpenAppLock + + + diff --git a/OpenAppLockReport/OpenAppLockReport.swift b/OpenAppLockReport/OpenAppLockReport.swift new file mode 100644 index 0000000..c7170aa --- /dev/null +++ b/OpenAppLockReport/OpenAppLockReport.swift @@ -0,0 +1,14 @@ +// +// OpenAppLockReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +@main +struct OpenAppLockReport: DeviceActivityReportExtension { + var body: some DeviceActivityReportScene { + RuleUsageReport() + } +} diff --git a/OpenAppLockReport/RuleUsageReport.swift b/OpenAppLockReport/RuleUsageReport.swift new file mode 100644 index 0000000..bc525d6 --- /dev/null +++ b/OpenAppLockReport/RuleUsageReport.swift @@ -0,0 +1,23 @@ +// +// RuleUsageReport.swift +// OpenAppLockReport +// + +import DeviceActivity +import SwiftUI + +/// Recomputes authoritative daily usage for time-limit rules as a side effect of +/// rendering. The view is intentionally empty — the app consumes the ledger +/// write, not the view. Runs only while the host app foregrounds a +/// `DeviceActivityReport(.ruleUsage, …)`. +struct RuleUsageReport: DeviceActivityReportScene { + let context: DeviceActivityReport.Context = .ruleUsage + let content: (Int) -> EmptyView = { _ in EmptyView() } + + func makeConfiguration( + representing data: DeviceActivityResults + ) async -> Int { + await RuleUsageReportWriter().write(from: data) + return 0 + } +} diff --git a/OpenAppLockReport/RuleUsageReportWriter.swift b/OpenAppLockReport/RuleUsageReportWriter.swift new file mode 100644 index 0000000..a060991 --- /dev/null +++ b/OpenAppLockReport/RuleUsageReportWriter.swift @@ -0,0 +1,43 @@ +// +// RuleUsageReportWriter.swift +// OpenAppLockReport +// + +import DeviceActivity +import FamilyControls +import Foundation +import SwiftUI + +/// Sums each enabled time-limit rule's true daily usage from Screen Time's own +/// per-application totals and records it as the authoritative figure in the +/// shared ledger. Attribution is by application token; category/web-domain +/// selections are not yet attributed (see spec §9 — confirm on device). +struct RuleUsageReportWriter { + func write(from data: DeviceActivityResults, now: Date = Date()) async { + let snapshots = RuleSnapshotStore().load() + .filter { $0.kind == .timeLimit && $0.isEnabled } + guard !snapshots.isEmpty else { return } + let selections = snapshots.map { ($0, AppSelectionCodec.decode($0.selectionData)) } + + var secondsByRule: [UUID: Double] = [:] + for await segment in data.flatMap(\.activitySegments) { + for await category in segment.categories { + for await app in category.applications { + guard let token = app.application.token else { continue } + let seconds = app.totalActivityDuration + for (snapshot, selection) in selections + where selection.applicationTokens.contains(token) { + secondsByRule[snapshot.id, default: 0] += seconds + } + } + } + } + + let ledger = UsageLedger() + for (snapshot, _) in selections { + let minutes = Int((secondsByRule[snapshot.id] ?? 0) / 60) + ledger.recordAuthoritativeMinutes( + minutes, for: snapshot.id, onDayContaining: now, asOf: now) + } + } +} diff --git a/OpenAppLockTests/RuleEnforcerTests.swift b/OpenAppLockTests/RuleEnforcerTests.swift index 4324ae3..82f1b48 100644 --- a/OpenAppLockTests/RuleEnforcerTests.swift +++ b/OpenAppLockTests/RuleEnforcerTests.swift @@ -27,6 +27,21 @@ struct RuleEnforcerTests { #expect(enforcer.blockingRuleIDs == [active.id]) } + @Test("Refresh establishes today's confirmed day-start for a time-limit rule") + func refreshEstablishesConfirmedStart() { + let shields = MockShieldController() + let suite = "enforcer-daystart-\(UUID().uuidString)" + let dayStarts = DayStartStore(defaults: UserDefaults(suiteName: suite)!) + let enforcer = RuleEnforcer(shields: shields, dayStarts: dayStarts) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig()), days: Weekday.everyDay) + + #expect(dayStarts.confirmedStart(for: rule.id) == nil) + enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + #expect(dayStarts.confirmedStart(for: rule.id) == utc.startOfDay(for: mondayDuringWork)) + } + @Test("Disabled rules are never shielded") func skipsDisabledRules() { let shields = MockShieldController() diff --git a/OpenAppLockTests/SchedulingTests.swift b/OpenAppLockTests/SchedulingTests.swift index c2130f2..e06ff57 100644 --- a/OpenAppLockTests/SchedulingTests.swift +++ b/OpenAppLockTests/SchedulingTests.swift @@ -114,13 +114,13 @@ struct MonitoringPlanTests { #expect(MonitoringPlan.ruleID(fromDailyActivityName: late) == nil) } - @Test("Minute checkpoints cover every minute up to the budget") - func minuteEvents() { - let events = MonitoringPlan.minuteEvents(forLimit: 45) - #expect(events.count == 45) - #expect(events[MonitoringPlan.minuteEventName(for: 1)] == 1) + @Test("A time limit registers a single block event at the budget") + func blockEvent() { + let events = MonitoringPlan.blockEvent(forLimit: 45) + #expect(events.count == 1) #expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45) - #expect(MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 17)) == 17) + #expect( + MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 45)) == 45) #expect(MonitoringPlan.minutes(fromEventName: "nope") == nil) } } @@ -172,7 +172,10 @@ struct RuleSchedulerTests { let name = MonitoringPlan.dailyActivityName(for: rule.id) #expect(monitor.monitoredNames == [name]) - #expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes) + #expect(monitor.startedEvents[name]?.count == 1) + #expect( + monitor.startedEvents[name]?[MonitoringPlan.minuteEventName(for: rule.dailyLimitMinutes)] + == rule.dailyLimitMinutes) #expect(store.snapshot(for: rule.id) != nil) } @@ -389,22 +392,38 @@ struct LimitEnforcementTests { return ( LimitEnforcement( snapshots: store, ledger: ledger, shields: shields, - sessions: OpenSessionStore(defaults: freshDefaults())), + sessions: OpenSessionStore(defaults: freshDefaults()), + dayStarts: DayStartStore(defaults: freshDefaults())), shields, ledger, store) } private func snapshot( - kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil + kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, + days: Set = Weekday.everyDay, pausedUntil: Date? = nil ) -> RuleSnapshot { RuleSnapshot( id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true, hardMode: false, blockAdultContent: false, selectionModeRaw: "block", - selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue), + selectionData: Data([1]), dayNumbers: days.map(\.rawValue), startMinutes: 0, endMinutes: 0, dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil ) } + @Test("An ineligible rule does not accrue usage from a checkpoint") + func ineligibleRuleDoesNotAccrue() { + let (enforcement, _, ledger, store) = makeEnforcement() + // Weekday-only rule; a checkpoint arrives on a Saturday (not scheduled). + let snap = snapshot(kind: .timeLimit, days: Weekday.weekdays) + store.save([snap]) + let saturday = date(2025, 1, 11, 10, 0) // 2025-01-11 is a Saturday + + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: saturday, calendar: utc) + + #expect( + ledger.usage(for: snap.id, onDayContaining: saturday, calendar: utc).minutesUsed == 0) + } + @Test("Day start shields open-limit rules so opens can be counted") func dayStartShieldsOpenLimit() { let (enforcement, shields, _, store) = makeEnforcement() @@ -434,6 +453,7 @@ struct LimitEnforcementTests { let (enforcement, shields, ledger, store) = makeEnforcement() let snap = snapshot(kind: .timeLimit, limit: 45) store.save([snap]) + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) @@ -454,6 +474,7 @@ struct LimitEnforcementTests { // late across midnight. It must not be recorded as today's usage, and // must not re-shield apps the user hasn't touched today. let earlyMorning = date(2025, 1, 6, 0, 30) + enforcement.handleDayStart(ruleID: snap.id, now: earlyMorning, calendar: utc) enforcement.handleUsageMinutes(45, ruleID: snap.id, now: earlyMorning, calendar: utc) #expect( @@ -470,6 +491,7 @@ struct LimitEnforcementTests { // 00:45 — 45 minutes have elapsed, so a 45-minute checkpoint is // physically possible today and must be honoured (boundary case). let quarterToOne = date(2025, 1, 6, 0, 45) + enforcement.handleDayStart(ruleID: snap.id, now: quarterToOne, calendar: utc) enforcement.handleUsageMinutes(45, ruleID: snap.id, now: quarterToOne, calendar: utc) #expect( @@ -477,6 +499,41 @@ struct LimitEnforcementTests { #expect(shields.shieldedRuleIDs == [snap.id]) } + @Test("A checkpoint before a confirmed day-start is dropped") + func checkpointBeforeConfirmedStartDropped() { + let (enforcement, shields, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit, limit: 45) + store.save([snap]) + // No handleDayStart → no confirmed start for today, so the event is a + // pre-boundary residual and must be dropped. + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + #expect(shields.shieldedRuleIDs.isEmpty) + } + + @Test("Day start zeroes today's time-limit ledger once, only on a transition") + func dayStartZeroesOnceOnTransition() { + let (enforcement, _, ledger, store) = makeEnforcement() + let snap = snapshot(kind: .timeLimit) + store.save([snap]) + // A stale value sitting in today's key (e.g. a pre-boundary write). + ledger.setUsage( + RuleUsage(minutesUsed: 45), for: snap.id, onDayContaining: monday, calendar: utc) + + // First day-start of the day: transition → zeroed. + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 0) + + // A legitimate accrual after the transition... + enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + + // ...survives a spurious same-day re-fire (no second zero). + enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc) + #expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20) + } + @Test("An Open press spends one open and lifts the shield") func openRequestSpendsAndLifts() { let (enforcement, shields, ledger, store) = makeEnforcement() @@ -654,3 +711,29 @@ struct ScheduleEnforcementTests { #expect(shields.shieldedRuleIDs.isEmpty) } } + +@MainActor +@Suite("Day-start store") +struct DayStartStoreTests { + private func makeStore() -> DayStartStore { + let name = "daystart-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return DayStartStore(defaults: defaults) + } + + @Test("Confirmed start round-trips and is day-scoped") + func roundTrip() { + let store = makeStore() + let id = UUID() + let monday = date(2025, 1, 6, 10, 0) + #expect(store.confirmedStart(for: id) == nil) + #expect(!store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + + store.setConfirmedStart(utc.startOfDay(for: monday), for: id) + #expect(store.confirmedStart(for: id) == utc.startOfDay(for: monday)) + #expect(store.hasConfirmedStart(for: id, onDayContaining: monday, calendar: utc)) + // A different day is not confirmed. + #expect(!store.hasConfirmedStart(for: id, onDayContaining: date(2025, 1, 7, 1, 0), calendar: utc)) + } +} diff --git a/OpenAppLockTests/UsageTests.swift b/OpenAppLockTests/UsageTests.swift index 4b837ff..ed6b7e3 100644 --- a/OpenAppLockTests/UsageTests.swift +++ b/OpenAppLockTests/UsageTests.swift @@ -71,6 +71,53 @@ struct UsageLedgerTests { #expect(ledger.usage(for: first, onDayContaining: tuesday, calendar: utc) == RuleUsage()) #expect(ledger.usage(for: second, onDayContaining: monday, calendar: utc) == RuleUsage()) } + + @Test("Effective minutes prefer a fresh authoritative reading, else fall back") + func effectiveMinutes() { + let now = date(2025, 1, 6, 10, 0) + var usage = RuleUsage(minutesUsed: 12) + // No authoritative reading → threshold count. + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + // Fresh authoritative → wins. + usage.authoritativeMinutesUsed = 20 + usage.authoritativeAsOf = now.addingTimeInterval(-30) + #expect(usage.effectiveMinutesUsed(asOf: now) == 20) + // Stale authoritative → threshold fallback. + usage.authoritativeAsOf = now.addingTimeInterval(-600) + #expect(usage.effectiveMinutesUsed(asOf: now) == 12) + } + + @Test("Usage round-trips authoritative fields and decodes legacy blobs") + func authoritativeCodable() throws { + var usage = RuleUsage(minutesUsed: 5, opensUsed: 2) + usage.authoritativeMinutesUsed = 30 + usage.authoritativeAsOf = date(2025, 1, 6, 10, 0) + let data = try JSONEncoder().encode(usage) + #expect(try JSONDecoder().decode(RuleUsage.self, from: data) == usage) + + // A blob written before the authoritative fields existed still decodes. + let legacy = Data(#"{"minutesUsed":7,"opensUsed":1}"#.utf8) + let decoded = try JSONDecoder().decode(RuleUsage.self, from: legacy) + #expect(decoded.minutesUsed == 7) + #expect(decoded.authoritativeMinutesUsed == nil) + #expect(decoded.authoritativeAsOf == nil) + } + + @Test("Authoritative minutes overwrite without disturbing the threshold count") + func recordAuthoritative() { + let ledger = makeLedger() + let id = UUID() + ledger.recordMinutesUsed(40, for: id, onDayContaining: monday, calendar: utc) + + ledger.recordAuthoritativeMinutes( + 12, for: id, onDayContaining: monday, asOf: monday, calendar: utc) + let read = ledger.usage(for: id, onDayContaining: monday, calendar: utc) + #expect(read.minutesUsed == 40) // threshold untouched + #expect(read.authoritativeMinutesUsed == 12) // authoritative recorded + #expect(read.authoritativeAsOf == monday) + // Effective prefers the (fresh) authoritative figure. + #expect(read.effectiveMinutesUsed(asOf: monday) == 12) + } } @MainActor @@ -144,6 +191,35 @@ struct UsageStatusTests { !RulePolicy.canEditAppLists( rules: [rule], usageFor: { _ in usage }, at: mondayMorning, calendar: utc)) } + + @Test("A fresh authoritative reading below budget keeps a rule inactive") + func freshAuthoritativeBelowBudgetInactive() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) // threshold says spent (phantom) + usage.authoritativeMinutesUsed = 5 // report says 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect(!rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } + + @Test("A fresh authoritative reading at budget blocks even if threshold lags") + func freshAuthoritativeAtBudgetBlocks() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 10) + usage.authoritativeMinutesUsed = 45 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + #expect( + rule.status(at: mondayMorning, calendar: utc, usage: usage) + == .active(until: date(2025, 1, 7, 0, 0))) + } + + @Test("A stale authoritative reading falls back to the threshold count") + func staleAuthoritativeUsesThreshold() { + let rule = timeLimitRule(limit: 45) + var usage = RuleUsage(minutesUsed: 45) + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-600) // stale + #expect(rule.status(at: mondayMorning, calendar: utc, usage: usage).isActive) + } } @MainActor @@ -206,6 +282,25 @@ struct UsageEnforcementTests { #expect(shields.shieldedRuleIDs.isEmpty) } + + @Test("A fresh authoritative reading below budget clears a phantom block") + func freshAuthoritativeClearsPhantomBlock() { + let shields = MockShieldController() + let ledger = MockUsageLedger() + let enforcer = RuleEnforcer(shields: shields, usage: ledger) + let rule = BlockingRule( + name: "Time Keeper", + configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)), + days: Weekday.everyDay) + var usage = RuleUsage(minutesUsed: 45) // threshold phantom + usage.authoritativeMinutesUsed = 5 + usage.authoritativeAsOf = mondayMorning.addingTimeInterval(-10) + ledger.usageByRule[rule.id] = usage + + enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc) + + #expect(shields.shieldedRuleIDs.isEmpty) // authoritative wins → not blocked + } } @MainActor @@ -225,13 +320,21 @@ struct UsageDisplayTests { @Test("Time-limit rows show minutes used of the budget") func timeLimitStrings() { let usage = RuleUsage(minutesUsed: 18) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage) == "18m of 45m used") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") + } + + @Test("Usage phrase reflects a fresh authoritative reading") + func usagePhrasePrefersFreshAuthoritative() { + var usage = RuleUsage(minutesUsed: 5) + usage.authoritativeMinutesUsed = 18 + usage.authoritativeAsOf = now.addingTimeInterval(-10) + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: usage, asOf: now) == "18m of 45m used") } @Test("Open-limit rows show opens used of the budget") func openLimitStrings() { let usage = RuleUsage(opensUsed: 2) - #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage) == "2 of 5 opens") + #expect(UsageDisplay.usagePhrase(for: openRule, usage: usage, asOf: now) == "2 of 5 opens") } /// Limit context adapts: the daily budget while untouched, live usage once @@ -261,7 +364,7 @@ struct UsageDisplayTests { @Test("Overshoot clamps to the budget") func overshootClamps() { let over = RuleUsage(minutesUsed: 60) - #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over) == "45m of 45m used") + #expect(UsageDisplay.usagePhrase(for: timeRule, usage: over, asOf: now) == "45m of 45m used") } @Test("Home subtitles prefix the rule kind so the type reads without the icon") diff --git a/Shared/DayStartStore.swift b/Shared/DayStartStore.swift new file mode 100644 index 0000000..bb923fc --- /dev/null +++ b/Shared/DayStartStore.swift @@ -0,0 +1,38 @@ +// +// DayStartStore.swift +// OpenAppLock +// + +import Foundation + +/// Per-rule "last confirmed daily-activity start", written when the monitor's +/// `intervalDidStart` fires (and defensively by the foreground enforcer). Lets +/// limit enforcement reject usage checkpoints that arrive before today's +/// interval boundary has been observed — i.e. yesterday's batched threshold +/// events flushed late across midnight. +final class DayStartStore { + private let defaults: UserDefaults + + init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + func confirmedStart(for ruleID: UUID) -> Date? { + defaults.object(forKey: key(ruleID)) as? Date + } + + func setConfirmedStart(_ dayStart: Date, for ruleID: UUID) { + defaults.set(dayStart, forKey: key(ruleID)) + } + + /// Whether the confirmed start equals the start of the day containing `date`. + func hasConfirmedStart( + for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current + ) -> Bool { + confirmedStart(for: ruleID) == calendar.startOfDay(for: date) + } + + private func key(_ ruleID: UUID) -> String { + "dayStart/\(ruleID.uuidString)" + } +} diff --git a/Shared/DeviceActivityReportContext.swift b/Shared/DeviceActivityReportContext.swift new file mode 100644 index 0000000..2fd6626 --- /dev/null +++ b/Shared/DeviceActivityReportContext.swift @@ -0,0 +1,13 @@ +// +// DeviceActivityReportContext.swift +// OpenAppLock +// + +import DeviceActivity +import SwiftUI + +extension DeviceActivityReport.Context { + /// The report scene that recomputes authoritative daily usage for limit + /// rules. Shared so the host app and the report extension name it the same. + static let ruleUsage = Self("Rule Usage") +} diff --git a/Shared/LimitEnforcement.swift b/Shared/LimitEnforcement.swift index 7bd8922..73b01b7 100644 --- a/Shared/LimitEnforcement.swift +++ b/Shared/LimitEnforcement.swift @@ -15,6 +15,8 @@ struct LimitEnforcement { let shields: ShieldApplying /// Granted-open session bookkeeping shared with the foreground enforcer. var sessions = OpenSessionStore() + /// Confirmed daily-activity starts, used to reject pre-boundary stale flushes. + var dayStarts = DayStartStore() /// Midnight (or monitoring start): fresh budgets. Open-limit rules are /// proactively shielded on enabled days so the shield can count opens; @@ -23,6 +25,7 @@ struct LimitEnforcement { guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, !snapshot.isPaused(at: now) else { return } + confirmDayStart(ruleID: ruleID, kind: snapshot.kind, now: now, calendar: calendar) let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) switch snapshot.kind { case .schedule: @@ -34,7 +37,7 @@ struct LimitEnforcement { shields.clearShield(ruleID: ruleID) } case .timeLimit: - if snapshot.limitReached(given: usage), + if snapshot.limitReached(given: usage, at: now), snapshot.isScheduledToday(at: now, calendar: calendar) { shield(snapshot) } else { @@ -43,6 +46,21 @@ struct LimitEnforcement { } } + /// Records today as the confirmed interval start for `ruleID`. On a genuine + /// new-day transition for a time-limit rule, zeroes today's ledger once so a + /// stale pre-boundary checkpoint cannot survive; a spurious same-day re-fire + /// must not erase legitimate usage. + private func confirmDayStart( + ruleID: UUID, kind: RuleKind, now: Date, calendar: Calendar + ) { + let today = calendar.startOfDay(for: now) + guard dayStarts.confirmedStart(for: ruleID) != today else { return } + dayStarts.setConfirmedStart(today, for: ruleID) + if kind == .timeLimit { + ledger.setUsage(RuleUsage(), for: ruleID, onDayContaining: now, calendar: calendar) + } + } + /// A cumulative usage checkpoint fired for a time-limit rule. func handleUsageMinutes( _ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current @@ -57,15 +75,21 @@ struct LimitEnforcement { let minutesSinceMidnight = Int( now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) guard minutes <= minutesSinceMidnight else { return } + // Reject events that arrive before today's interval boundary has been + // observed — yesterday's batched checkpoints flushed late across midnight. + guard dayStarts.hasConfirmedStart(for: ruleID, onDayContaining: now, calendar: calendar) + else { return } - ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) + // Record only for a rule that can actually be active today, so a stale or + // irrelevant event can't corrupt today's ledger for a rule that isn't. guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, snapshot.kind == .timeLimit, !snapshot.isPaused(at: now), snapshot.isScheduledToday(at: now, calendar: calendar) else { return } + ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) - if snapshot.limitReached(given: usage) { + if snapshot.limitReached(given: usage, at: now) { shield(snapshot) } } @@ -95,7 +119,7 @@ struct LimitEnforcement { !snapshot.isPaused(at: now) else { return false } let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) - guard !snapshot.limitReached(given: usage) else { return false } + guard !snapshot.limitReached(given: usage, at: now) else { return false } ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar) shields.clearShield(ruleID: ruleID) // Mark the session so neither enforcement path re-shields the app until diff --git a/Shared/MonitoringPlan.swift b/Shared/MonitoringPlan.swift index d17f39d..069dec4 100644 --- a/Shared/MonitoringPlan.swift +++ b/Shared/MonitoringPlan.swift @@ -71,15 +71,12 @@ enum MonitoringPlan { 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) - } - ) + /// The single cumulative-usage checkpoint for a time-limit rule: one event + /// at the budget, used by the monitor as the background block trigger. Live + /// sub-budget progress comes from the DeviceActivityReport extension, not a + /// per-minute chain (Screen Time batches sub-budget thresholds unreliably). + static func blockEvent(forLimit limitMinutes: Int) -> [String: Int] { + let minutes = max(1, limitMinutes) + return [minuteEventName(for: minutes): minutes] } } diff --git a/Shared/RuleSnapshot.swift b/Shared/RuleSnapshot.swift index 680066c..c5d58e4 100644 --- a/Shared/RuleSnapshot.swift +++ b/Shared/RuleSnapshot.swift @@ -43,10 +43,10 @@ struct RuleSnapshot: Codable, Equatable { } /// Whether the given usage exhausts this rule's daily budget. - func limitReached(given usage: RuleUsage) -> Bool { + func limitReached(given usage: RuleUsage, at now: Date = .now) -> Bool { switch kind { case .schedule: false - case .timeLimit: usage.minutesUsed >= dailyLimitMinutes + case .timeLimit: usage.effectiveMinutesUsed(asOf: now) >= dailyLimitMinutes case .openLimit: usage.opensUsed >= maxOpens } } diff --git a/Shared/UninstallProtectionPolicy.swift b/Shared/UninstallProtectionPolicy.swift index 15c26bb..c10633e 100644 --- a/Shared/UninstallProtectionPolicy.swift +++ b/Shared/UninstallProtectionPolicy.swift @@ -58,7 +58,7 @@ enum UninstallProtectionPolicy { guard let usage, snapshot.isScheduledToday(at: now, calendar: calendar) else { return false } - return snapshot.limitReached(given: usage) + return snapshot.limitReached(given: usage, at: now) } } } diff --git a/Shared/UsageLedger.swift b/Shared/UsageLedger.swift index 1dd1a05..bdc3d41 100644 --- a/Shared/UsageLedger.swift +++ b/Shared/UsageLedger.swift @@ -11,6 +11,27 @@ import Foundation struct RuleUsage: Codable, Equatable { var minutesUsed = 0 var opensUsed = 0 + /// The true daily total written by the DeviceActivityReport extension while + /// the app is foreground; preferred over `minutesUsed` when fresh. + var authoritativeMinutesUsed: Int? + /// When the authoritative total was computed. + var authoritativeAsOf: Date? + + /// How long an authoritative reading is trusted before falling back to the + /// threshold count. Tunable on device. + static let authoritativeFreshness: TimeInterval = 120 + + /// The daily minutes to use for display and the block decision: the report's + /// authoritative total when fresh, else the threshold count. + func effectiveMinutesUsed( + asOf now: Date, freshness: TimeInterval = RuleUsage.authoritativeFreshness + ) -> Int { + if let authoritative = authoritativeMinutesUsed, let asOf = authoritativeAsOf, + abs(now.timeIntervalSince(asOf)) <= freshness { + return authoritative + } + return minutesUsed + } } /// Read access to per-rule, per-day usage. @@ -66,6 +87,18 @@ final class UsageLedger: UsageReading { setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) } + /// Records the report's authoritative daily total without disturbing the + /// monotonic threshold count. + func recordAuthoritativeMinutes( + _ minutes: Int, for ruleID: UUID, onDayContaining date: Date, asOf: Date, + calendar: Calendar = .current + ) { + var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar) + usage.authoritativeMinutesUsed = minutes + usage.authoritativeAsOf = asOf + setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) + } + @discardableResult func recordOpen( for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current