Files
OpenAppLock/Severed/Services/ScreenTimeAuthorization.swift
Brendan Chen e6c87baeba feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on
Opal's My Apps tab:

- Onboarding with FamilyControls Screen Time authorization
- Apps home with Blocked Apps tiles and live rule cards
- New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery
- Rule editors: time windows (incl. overnight), day picker, app selection
  via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit
- Hard Mode: active hard rules cannot be edited, disabled, deleted, or
  unblocked until their window ends; soft rules pause until next window
- Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer
- 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg
  harness for in-memory storage, mocked authorization, seeded scenarios
- docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording

Known gap: background window transitions and time/open-limit thresholds
need a DeviceActivityMonitor extension; shields currently sync while the
app is running.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 12:35:52 -04:00

86 lines
2.3 KiB
Swift

//
// ScreenTimeAuthorization.swift
// Severed
//
import FamilyControls
import Foundation
import Observation
enum ScreenTimeAuthorizationStatus: Equatable, Sendable {
case notDetermined
case denied
case approved
}
/// Abstracts FamilyControls authorization so views and tests never touch
/// `AuthorizationCenter` directly.
protocol AuthorizationProviding {
var currentStatus: ScreenTimeAuthorizationStatus { get }
func requestAuthorization() async throws
}
/// Real Screen Time authorization via FamilyControls.
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
var currentStatus: ScreenTimeAuthorizationStatus {
switch AuthorizationCenter.shared.authorizationStatus {
case .approved: .approved
case .denied: .denied
case .notDetermined: .notDetermined
@unknown default: .notDetermined
}
}
func requestAuthorization() async throws {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
}
}
/// In-memory provider for unit and UI tests.
final class MockAuthorizationProvider: AuthorizationProviding {
var status: ScreenTimeAuthorizationStatus
var requestShouldFail: Bool
init(status: ScreenTimeAuthorizationStatus = .notDetermined, requestShouldFail: Bool = false) {
self.status = status
self.requestShouldFail = requestShouldFail
}
var currentStatus: ScreenTimeAuthorizationStatus { status }
func requestAuthorization() async throws {
if requestShouldFail {
status = .denied
throw FamilyControlsError.authorizationCanceled
}
status = .approved
}
}
/// Observable authorization state for the UI.
@Observable
final class ScreenTimeAuthorization {
private(set) var status: ScreenTimeAuthorizationStatus
private(set) var lastRequestFailed = false
private let provider: AuthorizationProviding
init(provider: AuthorizationProviding) {
self.provider = provider
self.status = provider.currentStatus
}
func refresh() {
status = provider.currentStatus
}
func request() async {
do {
try await provider.requestAuthorization()
lastRequestFailed = false
} catch {
lastRequestFailed = true
}
refresh()
}
}