- Shared/ layer compiled into the app and three new extension targets: rule snapshots in the app group, the usage ledger, monitoring-plan naming, and LimitEnforcement (shared, unit-tested event reactions) - RuleScheduler mirrors rules to the app group and reconciles DeviceActivity monitoring: one daily 00:00-23:59 activity per limit rule, with a cumulative usage-threshold event per budget minute for time limits; activities restart only when their configuration changes (a restart resets threshold accounting) - OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets, records usage minutes, shields at the budget, re-shields when a granted open session ends - OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times today' with an 'Open (Y left)' secondary button - OpenAppLockShieldAction: an Open press spends one open, lifts the rule's shield, and starts the ~15-minute one-shot session - extensions are classic NSExtension app extensions (the ExtensionKit product type expects an @main entry and made the app fail to install); shield-store tracking moved to app-group defaults so the app and extensions see one consistent set - maps iOS 26's new .approvedWithDataAccess authorization status to approved (it previously fell through @unknown default to notDetermined) - shares the OpenAppLock scheme (Xcode dropped the autocreated one when targets were added)
94 lines
3.2 KiB
Swift
94 lines
3.2 KiB
Swift
//
|
|
// UsageLedger.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// What a limit rule has consumed on a given day. Written by the
|
|
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
|
|
/// read by the app for display and enforcement.
|
|
struct RuleUsage: Codable, Equatable {
|
|
var minutesUsed = 0
|
|
var opensUsed = 0
|
|
}
|
|
|
|
/// Read access to per-rule, per-day usage.
|
|
protocol UsageReading: AnyObject {
|
|
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
|
|
}
|
|
|
|
extension UsageReading {
|
|
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
|
|
usage(for: ruleID, onDayContaining: date, calendar: .current)
|
|
}
|
|
}
|
|
|
|
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
|
|
/// and rule. Old days are simply ignored; midnight needs no reset step.
|
|
final class UsageLedger: UsageReading {
|
|
private let defaults: UserDefaults
|
|
|
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
/// "2026-06-12" — calendar-date key so budgets roll over at midnight.
|
|
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
|
|
let parts = calendar.dateComponents([.year, .month, .day], from: date)
|
|
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
|
|
}
|
|
|
|
func usage(
|
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
|
) -> RuleUsage {
|
|
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
|
|
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
|
|
else { return RuleUsage() }
|
|
return usage
|
|
}
|
|
|
|
func setUsage(
|
|
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
|
|
calendar: Calendar = .current
|
|
) {
|
|
guard let data = try? JSONEncoder().encode(usage) else { return }
|
|
defaults.set(data, forKey: key(ruleID, date, calendar))
|
|
}
|
|
|
|
/// Threshold events report cumulative totals, so minutes only move up.
|
|
func recordMinutesUsed(
|
|
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
|
|
calendar: Calendar = .current
|
|
) {
|
|
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
|
usage.minutesUsed = max(usage.minutesUsed, minutes)
|
|
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
|
}
|
|
|
|
@discardableResult
|
|
func recordOpen(
|
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
|
) -> RuleUsage {
|
|
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
|
usage.opensUsed += 1
|
|
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
|
return usage
|
|
}
|
|
|
|
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
|
|
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
|
|
}
|
|
}
|
|
|
|
/// Seedable in-memory usage for tests and UI-test scenarios.
|
|
final class MockUsageLedger: UsageReading {
|
|
var usageByRule: [UUID: RuleUsage] = [:]
|
|
|
|
func usage(
|
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
|
|
) -> RuleUsage {
|
|
usageByRule[ruleID] ?? RuleUsage()
|
|
}
|
|
}
|