diff --git a/OpenAppLock.xcodeproj/xcshareddata/xcodecloud/manifest.json b/OpenAppLock.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 0000000..0309649 --- /dev/null +++ b/OpenAppLock.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "10954c04-8bb5-43f2-b337-7231567734e7", + "targets" : [ + { + "id" : "B8353488-8E99-45EA-AD5C-394DF3CC2C87", + "name" : "OpenAppLock" + } + ] +} \ No newline at end of file diff --git a/OpenAppLock/Logic/RootDestination.swift b/OpenAppLock/Logic/RootDestination.swift index 730e9bf..9b27fc9 100644 --- a/OpenAppLock/Logic/RootDestination.swift +++ b/OpenAppLock/Logic/RootDestination.swift @@ -6,21 +6,30 @@ import Foundation /// Derives which top-level screen `RootView` should show from onboarding -/// completion and current Screen Time authorization. Only `.denied` gates the -/// app; `.notDetermined` routes to `.main` so the common launch (access already -/// granted, but reported as a stale `.notDetermined` while FamilyControls loads) -/// never flashes the access-required screen. A genuinely revoked user may see -/// `.main` briefly before the status settles to `.denied`. +/// completion, whether the launch settle window has elapsed, and observed +/// Screen Time authorization. +/// +/// Screen Time authorization is observed from a stream, and on a cold launch +/// that stream can emit a transient `.notDetermined` before the real value +/// (even for an approved user) — with no reliable way to tell a transient +/// `.notDetermined` from the decisive "access is off" one. So once onboarding is +/// complete the root holds a launch screen (`.launchSettling`) until +/// `hasCompletedLaunchSettle` — a fixed delay giving the stream time to settle — +/// and only then commits: `.approved` shows `.main`, every other status shows +/// `.screenTimeAccessRequired`. enum RootDestination: Equatable { case onboarding + case launchSettling case screenTimeAccessRequired case main static func resolve( hasCompletedOnboarding: Bool, - authorizationStatus: ScreenTimeAuthorizationStatus + authorizationStatus: ScreenTimeAuthorizationStatus, + hasCompletedLaunchSettle: Bool ) -> RootDestination { guard hasCompletedOnboarding else { return .onboarding } - return authorizationStatus == .denied ? .screenTimeAccessRequired : .main + guard hasCompletedLaunchSettle else { return .launchSettling } + return authorizationStatus == .approved ? .main : .screenTimeAccessRequired } } diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index b451286..efb7acb 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -11,6 +11,7 @@ import SwiftUI @main struct OpenAppLockApp: App { private let container: ModelContainer + private let launchSettleDelay: Duration @State private var authorization: ScreenTimeAuthorization @State private var notificationAuthorization: NotificationAuthorization @State private var enforcer: RuleEnforcer @@ -20,6 +21,9 @@ struct OpenAppLockApp: App { init() { let config = LaunchConfiguration.current + // UI tests must not sit through the cold-launch settle window. + launchSettleDelay = config.isUITesting ? .zero : RootView.defaultLaunchSettleDelay + // Diagnostic logging, configured before anything else can log: app-group // `Logs/` in production; a wiped per-launch temp dir under UI testing so // the export flow is hermetic and deterministic. @@ -117,7 +121,7 @@ struct OpenAppLockApp: App { var body: some Scene { WindowGroup { - RootView() + RootView(launchSettleDelay: launchSettleDelay) .environment(authorization) .environment(notificationAuthorization) .environment(enforcer) diff --git a/OpenAppLock/Services/ScreenTimeAuthorization.swift b/OpenAppLock/Services/ScreenTimeAuthorization.swift index 866eb75..39330a4 100644 --- a/OpenAppLock/Services/ScreenTimeAuthorization.swift +++ b/OpenAppLock/Services/ScreenTimeAuthorization.swift @@ -3,6 +3,7 @@ // OpenAppLock // +import Combine import FamilyControls import Foundation import Observation @@ -16,37 +17,70 @@ enum ScreenTimeAuthorizationStatus: Equatable, Sendable { /// Abstracts FamilyControls authorization so views and tests never touch /// `AuthorizationCenter` directly. protocol AuthorizationProviding { - var currentStatus: ScreenTimeAuthorizationStatus { get } + /// The stream of authorization status values — the source of truth. + /// FamilyControls publishes the current value on subscription and again on + /// every change. Note `.notDetermined` is a *decisive* "access is off" + /// value (it is what the system reports once Screen Time is toggled off in + /// Settings), not a transient loading state — the synchronous + /// `AuthorizationCenter.authorizationStatus` getter, by contrast, can stay + /// pinned at `.notDetermined` and must not be used. + var statusUpdates: AsyncStream { get } func requestAuthorization() async throws } /// Real Screen Time authorization via FamilyControls. struct FamilyControlsAuthorizationProvider: AuthorizationProviding { - var currentStatus: ScreenTimeAuthorizationStatus { - switch AuthorizationCenter.shared.authorizationStatus { - case .approved, .approvedWithDataAccess: .approved - case .denied: .denied - case .notDetermined: .notDetermined - @unknown default: .notDetermined + var statusUpdates: AsyncStream { + AsyncStream { continuation in + let task = Task { + for await status in AuthorizationCenter.shared.$authorizationStatus.values { + continuation.yield(Self.mapped(status)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } } } func requestAuthorization() async throws { try await AuthorizationCenter.shared.requestAuthorization(for: .individual) } + + private static func mapped(_ status: AuthorizationStatus) -> ScreenTimeAuthorizationStatus { + switch status { + case .approved, .approvedWithDataAccess: .approved + case .denied: .denied + case .notDetermined: .notDetermined + @unknown default: .notDetermined + } + } } /// In-memory provider for unit and UI tests. final class MockAuthorizationProvider: AuthorizationProviding { var status: ScreenTimeAuthorizationStatus var requestShouldFail: Bool + private let scriptedUpdates: [ScreenTimeAuthorizationStatus]? - init(status: ScreenTimeAuthorizationStatus = .notDetermined, requestShouldFail: Bool = false) { + init( + status: ScreenTimeAuthorizationStatus = .notDetermined, + requestShouldFail: Bool = false, + scriptedUpdates: [ScreenTimeAuthorizationStatus]? = nil + ) { self.status = status self.requestShouldFail = requestShouldFail + self.scriptedUpdates = scriptedUpdates } - var currentStatus: ScreenTimeAuthorizationStatus { status } + /// Emits `scriptedUpdates` when provided (to model an async-settling status), + /// otherwise the current status once, then finishes. + var statusUpdates: AsyncStream { + let values = scriptedUpdates ?? [status] + return AsyncStream { continuation in + for value in values { continuation.yield(value) } + continuation.finish() + } + } func requestAuthorization() async throws { if requestShouldFail { @@ -60,40 +94,43 @@ final class MockAuthorizationProvider: AuthorizationProviding { /// Observable authorization state for the UI. @Observable final class ScreenTimeAuthorization { - private(set) var status: ScreenTimeAuthorizationStatus + private(set) var status: ScreenTimeAuthorizationStatus = .notDetermined private(set) var lastRequestFailed = false private let provider: AuthorizationProviding + private var observationTask: Task? init(provider: AuthorizationProviding) { self.provider = provider - self.status = provider.currentStatus } - func refresh() { - status = provider.currentStatus + /// Starts observing authorization for the app's lifetime. FamilyControls + /// publishes the current value on subscription and on every later change + /// (e.g. Screen Time being turned off in Settings, reported as + /// `.notDetermined`). Safe to call more than once. + func startObserving() { + guard observationTask == nil else { return } + observationTask = Task { [weak self] in + await self?.observeStatusUpdates() + } } - /// Re-reads while the launch-time status stays `.notDetermined`, giving - /// FamilyControls a brief moment to settle so a revoked user's `.denied` - /// surfaces promptly, then gives up so the poll can't run forever. - func resolveAtLaunch() async { - refresh() - var remainingAttempts = 10 - while status == .notDetermined && remainingAttempts > 0 { - try? await Task.sleep(for: .milliseconds(50)) - refresh() - remainingAttempts -= 1 + /// Drains the provider's status stream into `status`. Split out from + /// `startObserving()` so tests can await it deterministically. + func observeStatusUpdates() async { + for await value in provider.statusUpdates { + status = value } } func request() async { do { try await provider.requestAuthorization() + status = .approved lastRequestFailed = false } catch { + status = .denied lastRequestFailed = true } - refresh() } } diff --git a/OpenAppLock/Views/LaunchScreenView.swift b/OpenAppLock/Views/LaunchScreenView.swift new file mode 100644 index 0000000..67e61a8 --- /dev/null +++ b/OpenAppLock/Views/LaunchScreenView.swift @@ -0,0 +1,24 @@ +// +// LaunchScreenView.swift +// OpenAppLock +// + +import SwiftUI + +/// A SwiftUI replica of the app's launch screen, shown briefly at cold launch +/// while Screen Time authorization settles (see `RootView` / `RootDestination`). +/// +/// iOS does not allow the *actual* launch screen to be held past the first +/// rendered frame, so the standard way to "extend" it is to display a view that +/// looks identical to it. Keep this view visually in sync with the launch screen +/// (`UILaunchScreen` in Info.plist / a launch storyboard) so the hand-off from +/// the system launch screen to this replica is seamless. The launch screen is +/// currently blank, so this is just the default background; when a real launch +/// screen is added, mirror its background and logo here. +struct LaunchScreenView: View { + var body: some View { + Color(.systemBackground) + .ignoresSafeArea() + .accessibilityIdentifier("launchScreenView") + } +} diff --git a/OpenAppLock/Views/RootView.swift b/OpenAppLock/Views/RootView.swift index 6afca7f..d2d6c5f 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -5,27 +5,39 @@ import SwiftUI -/// Gates the app on onboarding and on Screen Time authorization: until the -/// user has walked through the welcome and permission steps, nothing else is -/// reachable, and if access is later revoked from system Settings, -/// `MainView` is replaced by `ScreenTimeAccessRequiredView` until access is -/// restored. See `RootDestination.resolve` for the exact rule. +/// Gates the app on onboarding and on Screen Time authorization. On a cold +/// launch it holds a launch screen for a brief settle window (see +/// `launchSettleDelay`) so the observed authorization stream can settle past any +/// transient `.notDetermined` before the root commits; thereafter `MainView` is +/// shown while access is approved and replaced by `ScreenTimeAccessRequiredView` +/// when it is not. See `RootDestination.resolve` for the exact rule. struct RootView: View { + /// How long the launch screen is held on a cold launch while the Screen Time + /// authorization stream settles. Tunable; the UI-test harness passes `.zero`. + static let defaultLaunchSettleDelay: Duration = .milliseconds(250) + + var launchSettleDelay: Duration = RootView.defaultLaunchSettleDelay + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @Environment(ScreenTimeAuthorization.self) private var authorization @Environment(NotificationAuthorization.self) private var notificationAuthorization @Environment(\.scenePhase) private var scenePhase + @State private var hasCompletedLaunchSettle = false + var body: some View { Group { switch RootDestination.resolve( hasCompletedOnboarding: hasCompletedOnboarding, - authorizationStatus: authorization.status + authorizationStatus: authorization.status, + hasCompletedLaunchSettle: hasCompletedLaunchSettle ) { case .onboarding: OnboardingView { hasCompletedOnboarding = true } + case .launchSettling: + LaunchScreenView() case .screenTimeAccessRequired: ScreenTimeAccessRequiredView() .onAppear { @@ -38,23 +50,31 @@ struct RootView: View { MainView() } } - // Keep authorization state current app-wide: refresh at launch and on - // every foreground, so permission changes made in the system Settings app - // — including a notification revocation — are reflected everywhere, not - // only when the user opens a screen that happens to read them. Notification - // status is also mirrored into the app group here, so the scheduler keeps - // the time-limit warn activity registered without a Settings visit. + // Keep authorization state current app-wide. Screen Time authorization + // is *observed* rather than polled: FamilyControls loads it + // asynchronously and the synchronous getter can stay pinned at + // `.notDetermined`, so `startObserving()` drains the published stream to + // get the settled value and any later change (see `ScreenTimeAuthorization`). // - // `resolveAtLaunch()` polls past the stale `.notDetermined` - // FamilyControls reports right after a cold launch so a revoked user's - // `.denied` settles promptly (see `RootDestination`). + // Notification status is still refreshed on launch and every foreground + // — and mirrored into the app group — so a change made in Settings is + // reflected everywhere and the scheduler keeps the time-limit warn + // activity registered without a Settings visit. .task { - await authorization.resolveAtLaunch() + authorization.startObserving() await notificationAuthorization.refresh() } + // Hold the launch screen for a fixed window so the authorization stream + // can settle past any transient `.notDetermined` it emits on a cold + // launch, then commit to whatever it resolved to (see `RootDestination`). + .task { + try? await Task.sleep(for: launchSettleDelay) + withAnimation { + hasCompletedLaunchSettle = true + } + } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } - authorization.refresh() Task { await notificationAuthorization.refresh() } } } diff --git a/OpenAppLockTests/RootDestinationTests.swift b/OpenAppLockTests/RootDestinationTests.swift index 79d0d53..cb3fd6f 100644 --- a/OpenAppLockTests/RootDestinationTests.swift +++ b/OpenAppLockTests/RootDestinationTests.swift @@ -19,37 +19,63 @@ struct RootDestinationTests { ] ) func onboardingIncomplete(status: ScreenTimeAuthorizationStatus) { - let destination = RootDestination.resolve( - hasCompletedOnboarding: false, - authorizationStatus: status - ) - #expect(destination == .onboarding) + for hasCompletedLaunchSettle in [true, false] { + let destination = RootDestination.resolve( + hasCompletedOnboarding: false, + authorizationStatus: status, + hasCompletedLaunchSettle: hasCompletedLaunchSettle + ) + #expect(destination == .onboarding) + } } @Test( """ - Onboarding complete routes to main for every status except denied, so a \ - stale launch-time .notDetermined shows the real app instead of flashing \ - the access-required screen + Onboarding complete but the launch settle window has not elapsed holds the \ + launch screen, so a transient launch-time .notDetermined can't flash the \ + wrong screen before the stream settles """, arguments: [ - ScreenTimeAuthorizationStatus.approved, - .notDetermined, + ScreenTimeAuthorizationStatus.notDetermined, + .denied, + .approved, ] ) - func onboardingCompleteNonDeniedRoutesToMain(status: ScreenTimeAuthorizationStatus) { + func onboardingCompleteStillSettling(status: ScreenTimeAuthorizationStatus) { + let destination = RootDestination.resolve( + hasCompletedOnboarding: true, + authorizationStatus: status, + hasCompletedLaunchSettle: false + ) + #expect(destination == .launchSettling) + } + + @Test("Onboarding complete and settled with approved authorization routes to main") + func onboardingCompleteSettledApproved() { let destination = RootDestination.resolve( hasCompletedOnboarding: true, - authorizationStatus: status + authorizationStatus: .approved, + hasCompletedLaunchSettle: true ) #expect(destination == .main) } - @Test("Onboarding complete with denied authorization routes to the access-required screen") - func onboardingCompleteDenied() { + @Test( + """ + Onboarding complete and settled with a non-approved status routes to the \ + access-required screen — including .notDetermined, the decisive value the \ + system reports when Screen Time is turned off in Settings + """, + arguments: [ + ScreenTimeAuthorizationStatus.notDetermined, + .denied, + ] + ) + func onboardingCompleteSettledNonApproved(status: ScreenTimeAuthorizationStatus) { let destination = RootDestination.resolve( hasCompletedOnboarding: true, - authorizationStatus: .denied + authorizationStatus: status, + hasCompletedLaunchSettle: true ) #expect(destination == .screenTimeAccessRequired) } diff --git a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift index 83f437f..199c003 100644 --- a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift +++ b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift @@ -7,63 +7,42 @@ import Testing @testable import OpenAppLock -/// Provider that reports `.notDetermined` for its first reads and then the -/// settled status, mimicking how FamilyControls loads authorization -/// asynchronously and reports a stale `.notDetermined` right after a cold -/// launch before the real value arrives. @MainActor -private final class DelayedSettleAuthorizationProvider: AuthorizationProviding { - private var reads = 0 - private let settledStatus: ScreenTimeAuthorizationStatus - private let settlesAfterReads: Int - - init(settledStatus: ScreenTimeAuthorizationStatus, settlesAfterReads: Int) { - self.settledStatus = settledStatus - self.settlesAfterReads = settlesAfterReads - } - - var currentStatus: ScreenTimeAuthorizationStatus { - defer { reads += 1 } - return reads >= settlesAfterReads ? settledStatus : .notDetermined - } - - func requestAuthorization() async throws {} -} - -@MainActor -@Suite("Screen Time authorization launch resolution") +@Suite("Screen Time authorization observation") struct ScreenTimeAuthorizationTests { - @Test("A definitive status is available immediately at init") - func definitiveStatusAtInit() { - let approved = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .approved)) - #expect(approved.status == .approved) - - let denied = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .denied)) - #expect(denied.status == .denied) + @Test("Status starts .notDetermined until the observed stream posts a value") + func statusStartsNotDetermined() { + let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .approved)) + #expect(auth.status == .notDetermined) } @Test( """ - resolveAtLaunch settles past the stale launch-time .notDetermined to a \ - revoked user's real .denied, so the access-required screen can surface \ - without waiting for the next foreground + Draining the stream lands on its final value, so a transient launch-time \ + .notDetermined followed by the real .approved resolves to .approved """ ) - func resolveAtLaunchSettlesToDenied() async { - let provider = DelayedSettleAuthorizationProvider(settledStatus: .denied, settlesAfterReads: 2) + func observationResolvesTransientNotDeterminedToApproved() async { + let provider = MockAuthorizationProvider( + status: .notDetermined, + scriptedUpdates: [.notDetermined, .approved] + ) let auth = ScreenTimeAuthorization(provider: provider) - #expect(auth.status == .notDetermined) - await auth.resolveAtLaunch() + await auth.observeStatusUpdates() - #expect(auth.status == .denied) + #expect(auth.status == .approved) } - @Test("resolveAtLaunch gives up gracefully when the status never settles") - func resolveAtLaunchGivesUpWhenNeverSettles() async { - let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .notDetermined)) + @Test("Draining a stream whose final value is .notDetermined leaves status .notDetermined") + func observationResolvesToNotDetermined() async { + let provider = MockAuthorizationProvider( + status: .notDetermined, + scriptedUpdates: [.approved, .notDetermined] + ) + let auth = ScreenTimeAuthorization(provider: provider) - await auth.resolveAtLaunch() + await auth.observeStatusUpdates() #expect(auth.status == .notDetermined) }