From 2610aa2beff999939270db2a43a5722c1f1d6b82 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 21 Jul 2026 19:09:35 -0400 Subject: [PATCH 1/5] fix: observe Screen Time authorization instead of polling the sync getter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthorizationCenter.shared.authorizationStatus loads asynchronously and its synchronous getter can stay pinned at .notDetermined indefinitely, so polling it (as both refresh() and the launch resolver did) never delivered the settled value — leaving the app unable to tell whether access was granted. Drive status from an AsyncStream over AuthorizationCenter.shared.$authorizationStatus instead: ScreenTimeAuthorization.startObserving() drains the published stream for the app's lifetime, so the real value (and any later change, e.g. a revocation from Settings) arrives without relying on the stale synchronous read. RootView starts the observation in .task and no longer refreshes the sync getter on foreground. AuthorizationProviding gains a statusUpdates stream; the mock can script a sequence to model the async settle. Verification is on device — FamilyControls does not run on the Simulator. Co-Authored-By: Claude Opus 4.8 --- .../Services/ScreenTimeAuthorization.swift | 83 +++++++++++++++---- OpenAppLock/Views/RootView.swift | 21 +++-- .../ScreenTimeAuthorizationTests.swift | 67 ++++++--------- 3 files changed, 99 insertions(+), 72 deletions(-) diff --git a/OpenAppLock/Services/ScreenTimeAuthorization.swift b/OpenAppLock/Services/ScreenTimeAuthorization.swift index 866eb75..a8ce95e 100644 --- a/OpenAppLock/Services/ScreenTimeAuthorization.swift +++ b/OpenAppLock/Services/ScreenTimeAuthorization.swift @@ -3,6 +3,7 @@ // OpenAppLock // +import Combine import FamilyControls import Foundation import Observation @@ -17,37 +18,75 @@ enum ScreenTimeAuthorizationStatus: Equatable, Sendable { /// `AuthorizationCenter` directly. protocol AuthorizationProviding { var currentStatus: ScreenTimeAuthorizationStatus { get } + /// A stream of authorization status values, delivered as FamilyControls + /// loads the real value and as it later changes. FamilyControls loads + /// authorization asynchronously and the synchronous `currentStatus` can + /// stay pinned at `.notDetermined` indefinitely, so this stream — not + /// `currentStatus` — is the reliable source of the settled status. + 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 + Self.mapped(AuthorizationCenter.shared.authorizationStatus) + } + + 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 mimic 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 { status = .denied @@ -64,29 +103,37 @@ final class ScreenTimeAuthorization { 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 changes for the app's lifetime. The + /// synchronous `currentStatus` can stay pinned at `.notDetermined` because + /// FamilyControls loads it asynchronously, so draining the provider's + /// stream is what delivers the settled value and any later change (e.g. a + /// revocation from Settings). 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 refresh() { + status = provider.currentStatus + } + func request() async { do { try await provider.requestAuthorization() diff --git a/OpenAppLock/Views/RootView.swift b/OpenAppLock/Views/RootView.swift index 6afca7f..adf3088 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -38,23 +38,22 @@ 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() } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } - authorization.refresh() Task { await notificationAuthorization.refresh() } } } diff --git a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift index 83f437f..5681350 100644 --- a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift +++ b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift @@ -7,64 +7,45 @@ 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("Init seeds status from the provider's current status") + func initSeedsFromCurrentStatus() { + let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .notDetermined)) + #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 + Observing the provider's stream settles status past the stale launch-time \ + .notDetermined to the real .approved, which the synchronous getter never \ + delivered """ ) - func resolveAtLaunchSettlesToDenied() async { - let provider = DelayedSettleAuthorizationProvider(settledStatus: .denied, settlesAfterReads: 2) + func observationSettlesToApproved() 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("Observing the provider's stream surfaces a revoked user's .denied") + func observationSettlesToDenied() async { + let provider = MockAuthorizationProvider( + status: .notDetermined, + scriptedUpdates: [.notDetermined, .denied] + ) + let auth = ScreenTimeAuthorization(provider: provider) - await auth.resolveAtLaunch() + await auth.observeStatusUpdates() - #expect(auth.status == .notDetermined) + #expect(auth.status == .denied) } } From c1c9d0db11613d260fb52e74fe57142f1cb79e8f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 21 Jul 2026 19:34:13 -0400 Subject: [PATCH 2/5] fix: treat .notDetermined as decisive; make the status stream the sole source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .notDetermined is not a transient loading state — it is the decisive value the system reports when Screen Time is turned off in Settings. The observed $authorizationStatus stream already reflects the correct value on launch (approved if approved, notDetermined if off), so drive routing entirely from it: - Drop the synchronous `currentStatus` getter from AuthorizationProviding and both providers; the stream is the only source of truth. - ScreenTimeAuthorization tracks `hasReceivedStatus` (whether the stream has posted a value yet) and no longer seeds from the synchronous getter. - RootDestination: until the stream posts, show .main so the common approved launch never flickers; once it posts, only .approved shows .main and every other status — including .notDetermined — routes to .screenTimeAccessRequired. Verification is on device — FamilyControls does not run on the Simulator. Co-Authored-By: Claude Opus 4.8 --- OpenAppLock.xcodeproj/project.pbxproj | 10 ++++ .../xcshareddata/xcodecloud/manifest.json | 9 +++ OpenAppLock/Logic/RootDestination.swift | 19 ++++--- .../Services/ScreenTimeAuthorization.swift | 47 ++++++++-------- OpenAppLock/Views/RootView.swift | 3 +- OpenAppLockTests/RootDestinationTests.swift | 56 ++++++++++++++----- .../ScreenTimeAuthorizationTests.swift | 34 +++++------ 7 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 OpenAppLock.xcodeproj/xcshareddata/xcodecloud/manifest.json diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index 32e4f19..c3bb8bc 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -767,6 +767,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLock/OpenAppLock.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -813,6 +814,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLock/OpenAppLock.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -951,6 +953,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; @@ -977,6 +980,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; @@ -1003,6 +1007,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; @@ -1029,6 +1034,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; @@ -1055,6 +1061,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; @@ -1081,6 +1088,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; @@ -1107,6 +1115,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockReport/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; @@ -1133,6 +1142,7 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockReport/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; 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..7216d0d 100644 --- a/OpenAppLock/Logic/RootDestination.swift +++ b/OpenAppLock/Logic/RootDestination.swift @@ -6,11 +6,14 @@ 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 and observed Screen Time authorization. +/// +/// The status comes from an observed stream, not a synchronous read. Until that +/// stream has delivered a value (`hasReceivedAuthorizationStatus` is false), the +/// root shows `.main` so the common approved launch never flickers. Once a value +/// has arrived, only `.approved` shows `.main`; every other status — including +/// `.notDetermined`, the decisive value the system reports when Screen Time is +/// turned off in Settings — routes to `.screenTimeAccessRequired`. enum RootDestination: Equatable { case onboarding case screenTimeAccessRequired @@ -18,9 +21,11 @@ enum RootDestination: Equatable { static func resolve( hasCompletedOnboarding: Bool, - authorizationStatus: ScreenTimeAuthorizationStatus + authorizationStatus: ScreenTimeAuthorizationStatus, + hasReceivedAuthorizationStatus: Bool ) -> RootDestination { guard hasCompletedOnboarding else { return .onboarding } - return authorizationStatus == .denied ? .screenTimeAccessRequired : .main + guard hasReceivedAuthorizationStatus else { return .main } + return authorizationStatus == .approved ? .main : .screenTimeAccessRequired } } diff --git a/OpenAppLock/Services/ScreenTimeAuthorization.swift b/OpenAppLock/Services/ScreenTimeAuthorization.swift index a8ce95e..697bb90 100644 --- a/OpenAppLock/Services/ScreenTimeAuthorization.swift +++ b/OpenAppLock/Services/ScreenTimeAuthorization.swift @@ -17,22 +17,19 @@ enum ScreenTimeAuthorizationStatus: Equatable, Sendable { /// Abstracts FamilyControls authorization so views and tests never touch /// `AuthorizationCenter` directly. protocol AuthorizationProviding { - var currentStatus: ScreenTimeAuthorizationStatus { get } - /// A stream of authorization status values, delivered as FamilyControls - /// loads the real value and as it later changes. FamilyControls loads - /// authorization asynchronously and the synchronous `currentStatus` can - /// stay pinned at `.notDetermined` indefinitely, so this stream — not - /// `currentStatus` — is the reliable source of the settled status. + /// 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 { - Self.mapped(AuthorizationCenter.shared.authorizationStatus) - } - var statusUpdates: AsyncStream { AsyncStream { continuation in let task = Task { @@ -75,9 +72,7 @@ final class MockAuthorizationProvider: AuthorizationProviding { self.scriptedUpdates = scriptedUpdates } - var currentStatus: ScreenTimeAuthorizationStatus { status } - - /// Emits `scriptedUpdates` when provided (to mimic an async-settling 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] @@ -99,7 +94,12 @@ 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 + + /// Whether the provider's stream has delivered a value yet. The stream is + /// the source of truth; until it posts, the root shows the main flow so the + /// common (approved) launch never flickers — see `RootDestination`. + private(set) var hasReceivedStatus = false private(set) var lastRequestFailed = false private let provider: AuthorizationProviding @@ -107,14 +107,12 @@ final class ScreenTimeAuthorization { init(provider: AuthorizationProviding) { self.provider = provider - self.status = provider.currentStatus } - /// Starts observing authorization changes for the app's lifetime. The - /// synchronous `currentStatus` can stay pinned at `.notDetermined` because - /// FamilyControls loads it asynchronously, so draining the provider's - /// stream is what delivers the settled value and any later change (e.g. a - /// revocation from Settings). Safe to call more than once. + /// 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 @@ -127,20 +125,19 @@ final class ScreenTimeAuthorization { func observeStatusUpdates() async { for await value in provider.statusUpdates { status = value + hasReceivedStatus = true } } - func refresh() { - status = provider.currentStatus - } - func request() async { do { try await provider.requestAuthorization() + status = .approved lastRequestFailed = false } catch { + status = .denied lastRequestFailed = true } - refresh() + hasReceivedStatus = true } } diff --git a/OpenAppLock/Views/RootView.swift b/OpenAppLock/Views/RootView.swift index adf3088..372511d 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -20,7 +20,8 @@ struct RootView: View { Group { switch RootDestination.resolve( hasCompletedOnboarding: hasCompletedOnboarding, - authorizationStatus: authorization.status + authorizationStatus: authorization.status, + hasReceivedAuthorizationStatus: authorization.hasReceivedStatus ) { case .onboarding: OnboardingView { diff --git a/OpenAppLockTests/RootDestinationTests.swift b/OpenAppLockTests/RootDestinationTests.swift index 79d0d53..214f048 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 hasReceived in [true, false] { + let destination = RootDestination.resolve( + hasCompletedOnboarding: false, + authorizationStatus: status, + hasReceivedAuthorizationStatus: hasReceived + ) + #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 no status received yet routes to main, so the \ + common approved launch never flashes the access-required screen while the \ + stream posts its first value """, arguments: [ - ScreenTimeAuthorizationStatus.approved, - .notDetermined, + ScreenTimeAuthorizationStatus.notDetermined, + .denied, + .approved, ] ) - func onboardingCompleteNonDeniedRoutesToMain(status: ScreenTimeAuthorizationStatus) { + func onboardingCompleteNoStatusReceived(status: ScreenTimeAuthorizationStatus) { let destination = RootDestination.resolve( hasCompletedOnboarding: true, - authorizationStatus: status + authorizationStatus: status, + hasReceivedAuthorizationStatus: false ) #expect(destination == .main) } - @Test("Onboarding complete with denied authorization routes to the access-required screen") - func onboardingCompleteDenied() { + @Test("Onboarding complete with a received approved status routes to main") + func onboardingCompleteApproved() { + let destination = RootDestination.resolve( + hasCompletedOnboarding: true, + authorizationStatus: .approved, + hasReceivedAuthorizationStatus: true + ) + #expect(destination == .main) + } + + @Test( + """ + Onboarding complete with a received non-approved status routes to the \ + access-required screen — including .notDetermined, which is the decisive \ + value the system reports when Screen Time is turned off in Settings + """, + arguments: [ + ScreenTimeAuthorizationStatus.notDetermined, + .denied, + ] + ) + func onboardingCompleteNonApproved(status: ScreenTimeAuthorizationStatus) { let destination = RootDestination.resolve( hasCompletedOnboarding: true, - authorizationStatus: .denied + authorizationStatus: status, + hasReceivedAuthorizationStatus: true ) #expect(destination == .screenTimeAccessRequired) } diff --git a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift index 5681350..0d13490 100644 --- a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift +++ b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift @@ -10,42 +10,42 @@ import Testing @MainActor @Suite("Screen Time authorization observation") struct ScreenTimeAuthorizationTests { - @Test("Init seeds status from the provider's current status") - func initSeedsFromCurrentStatus() { - let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .notDetermined)) - #expect(auth.status == .notDetermined) + @Test("Before the stream posts, no status has been received") + func noStatusReceivedBeforeStreamPosts() { + let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .approved)) + #expect(!auth.hasReceivedStatus) } - @Test( - """ - Observing the provider's stream settles status past the stale launch-time \ - .notDetermined to the real .approved, which the synchronous getter never \ - delivered - """ - ) - func observationSettlesToApproved() async { + @Test("Observing the stream delivers approved and marks the status received") + func observationDeliversApproved() async { let provider = MockAuthorizationProvider( status: .notDetermined, scriptedUpdates: [.notDetermined, .approved] ) let auth = ScreenTimeAuthorization(provider: provider) - #expect(auth.status == .notDetermined) await auth.observeStatusUpdates() #expect(auth.status == .approved) + #expect(auth.hasReceivedStatus) } - @Test("Observing the provider's stream surfaces a revoked user's .denied") - func observationSettlesToDenied() async { + @Test( + """ + A .notDetermined value from the stream is decisive: it is marked received \ + (so the root routes to access-required), not treated as still pending + """ + ) + func observationDeliversNotDeterminedAsDecisive() async { let provider = MockAuthorizationProvider( status: .notDetermined, - scriptedUpdates: [.notDetermined, .denied] + scriptedUpdates: [.notDetermined] ) let auth = ScreenTimeAuthorization(provider: provider) await auth.observeStatusUpdates() - #expect(auth.status == .denied) + #expect(auth.status == .notDetermined) + #expect(auth.hasReceivedStatus) } } From 45d096d7076970d3bf1f7488ad0ba9866af8e74b Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 21 Jul 2026 19:36:03 -0400 Subject: [PATCH 3/5] chore: remove hardcoded DEVELOPMENT_TEAM ID from project Drop the 10 DEVELOPMENT_TEAM = 4A9XHUS87Q entries so the team ID is not committed to the repo. Signing team is set locally per developer; simulator builds and the test suite need no team. Co-Authored-By: Claude Opus 4.8 --- OpenAppLock.xcodeproj/project.pbxproj | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/OpenAppLock.xcodeproj/project.pbxproj b/OpenAppLock.xcodeproj/project.pbxproj index c3bb8bc..32e4f19 100644 --- a/OpenAppLock.xcodeproj/project.pbxproj +++ b/OpenAppLock.xcodeproj/project.pbxproj @@ -767,7 +767,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLock/OpenAppLock.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -814,7 +813,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLock/OpenAppLock.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -953,7 +951,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; @@ -980,7 +977,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockMonitor/OpenAppLockMonitor.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockMonitor/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockMonitor; @@ -1007,7 +1003,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; @@ -1034,7 +1029,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldConfig/OpenAppLockShieldConfig.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldConfig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldConfig; @@ -1061,7 +1055,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; @@ -1088,7 +1081,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockShieldAction/OpenAppLockShieldAction.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockShieldAction/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockShieldAction; @@ -1115,7 +1107,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockReport/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; @@ -1142,7 +1133,6 @@ CODE_SIGN_ENTITLEMENTS = OpenAppLockReport/OpenAppLockReport.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 4A9XHUS87Q; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenAppLockReport/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OpenAppLockReport; From 3d370a5306d47844ec7901efbb35374c11b89fab Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 21 Jul 2026 19:52:15 -0400 Subject: [PATCH 4/5] fix: hold a launch screen for a fixed settle window before gating on auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold launch the observed authorization stream can emit a transient .notDetermined before the real value (even for an approved user), with no reliable way to distinguish that transient from the decisive "access is off" .notDetermined. Rather than guess, hold a launch screen for a fixed window so the stream can settle, then commit to whatever it resolved to. - RootDestination gains a .launchSettling case: once onboarding is complete, route there until hasCompletedLaunchSettle, then .approved -> .main and every other status -> .screenTimeAccessRequired. - RootView holds LaunchScreenView for launchSettleDelay (default 750ms) via a .task, then flips the flag. LaunchScreenView is a SwiftUI replica of the (currently blank) launch screen — iOS can't hold the real one past the first frame, so a matching replica is the standard way to extend it; keep it in sync with the launch screen when one is added. - OpenAppLockApp passes .zero delay under UI testing so the suite isn't slowed. - Drop the now-unused hasReceivedStatus. Verification is on device — FamilyControls does not run on the Simulator. Co-Authored-By: Claude Opus 4.8 --- OpenAppLock/Logic/RootDestination.swift | 22 +++++++------ OpenAppLock/OpenAppLockApp.swift | 6 +++- .../Services/ScreenTimeAuthorization.swift | 7 ---- OpenAppLock/Views/LaunchScreenView.swift | 24 ++++++++++++++ OpenAppLock/Views/RootView.swift | 30 +++++++++++++---- OpenAppLockTests/RootDestinationTests.swift | 32 +++++++++---------- .../ScreenTimeAuthorizationTests.swift | 28 ++++++++-------- 7 files changed, 95 insertions(+), 54 deletions(-) create mode 100644 OpenAppLock/Views/LaunchScreenView.swift diff --git a/OpenAppLock/Logic/RootDestination.swift b/OpenAppLock/Logic/RootDestination.swift index 7216d0d..9b27fc9 100644 --- a/OpenAppLock/Logic/RootDestination.swift +++ b/OpenAppLock/Logic/RootDestination.swift @@ -6,26 +6,30 @@ import Foundation /// Derives which top-level screen `RootView` should show from onboarding -/// completion and observed Screen Time authorization. +/// completion, whether the launch settle window has elapsed, and observed +/// Screen Time authorization. /// -/// The status comes from an observed stream, not a synchronous read. Until that -/// stream has delivered a value (`hasReceivedAuthorizationStatus` is false), the -/// root shows `.main` so the common approved launch never flickers. Once a value -/// has arrived, only `.approved` shows `.main`; every other status — including -/// `.notDetermined`, the decisive value the system reports when Screen Time is -/// turned off in Settings — routes to `.screenTimeAccessRequired`. +/// 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, - hasReceivedAuthorizationStatus: Bool + hasCompletedLaunchSettle: Bool ) -> RootDestination { guard hasCompletedOnboarding else { return .onboarding } - guard hasReceivedAuthorizationStatus else { return .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 697bb90..39330a4 100644 --- a/OpenAppLock/Services/ScreenTimeAuthorization.swift +++ b/OpenAppLock/Services/ScreenTimeAuthorization.swift @@ -95,11 +95,6 @@ final class MockAuthorizationProvider: AuthorizationProviding { @Observable final class ScreenTimeAuthorization { private(set) var status: ScreenTimeAuthorizationStatus = .notDetermined - - /// Whether the provider's stream has delivered a value yet. The stream is - /// the source of truth; until it posts, the root shows the main flow so the - /// common (approved) launch never flickers — see `RootDestination`. - private(set) var hasReceivedStatus = false private(set) var lastRequestFailed = false private let provider: AuthorizationProviding @@ -125,7 +120,6 @@ final class ScreenTimeAuthorization { func observeStatusUpdates() async { for await value in provider.statusUpdates { status = value - hasReceivedStatus = true } } @@ -138,6 +132,5 @@ final class ScreenTimeAuthorization { status = .denied lastRequestFailed = true } - hasReceivedStatus = true } } 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 372511d..d7e44e2 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -5,28 +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(750) + + 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, - hasReceivedAuthorizationStatus: authorization.hasReceivedStatus + hasCompletedLaunchSettle: hasCompletedLaunchSettle ) { case .onboarding: OnboardingView { hasCompletedOnboarding = true } + case .launchSettling: + LaunchScreenView() case .screenTimeAccessRequired: ScreenTimeAccessRequiredView() .onAppear { @@ -53,6 +64,13 @@ struct RootView: View { 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) + hasCompletedLaunchSettle = true + } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } Task { await notificationAuthorization.refresh() } diff --git a/OpenAppLockTests/RootDestinationTests.swift b/OpenAppLockTests/RootDestinationTests.swift index 214f048..cb3fd6f 100644 --- a/OpenAppLockTests/RootDestinationTests.swift +++ b/OpenAppLockTests/RootDestinationTests.swift @@ -19,11 +19,11 @@ struct RootDestinationTests { ] ) func onboardingIncomplete(status: ScreenTimeAuthorizationStatus) { - for hasReceived in [true, false] { + for hasCompletedLaunchSettle in [true, false] { let destination = RootDestination.resolve( hasCompletedOnboarding: false, authorizationStatus: status, - hasReceivedAuthorizationStatus: hasReceived + hasCompletedLaunchSettle: hasCompletedLaunchSettle ) #expect(destination == .onboarding) } @@ -31,9 +31,9 @@ struct RootDestinationTests { @Test( """ - Onboarding complete but no status received yet routes to main, so the \ - common approved launch never flashes the access-required screen while the \ - stream posts its first value + 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.notDetermined, @@ -41,41 +41,41 @@ struct RootDestinationTests { .approved, ] ) - func onboardingCompleteNoStatusReceived(status: ScreenTimeAuthorizationStatus) { + func onboardingCompleteStillSettling(status: ScreenTimeAuthorizationStatus) { let destination = RootDestination.resolve( hasCompletedOnboarding: true, authorizationStatus: status, - hasReceivedAuthorizationStatus: false + hasCompletedLaunchSettle: false ) - #expect(destination == .main) + #expect(destination == .launchSettling) } - @Test("Onboarding complete with a received approved status routes to main") - func onboardingCompleteApproved() { + @Test("Onboarding complete and settled with approved authorization routes to main") + func onboardingCompleteSettledApproved() { let destination = RootDestination.resolve( hasCompletedOnboarding: true, authorizationStatus: .approved, - hasReceivedAuthorizationStatus: true + hasCompletedLaunchSettle: true ) #expect(destination == .main) } @Test( """ - Onboarding complete with a received non-approved status routes to the \ - access-required screen — including .notDetermined, which is the decisive \ - value the system reports when Screen Time is turned off in Settings + 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 onboardingCompleteNonApproved(status: ScreenTimeAuthorizationStatus) { + func onboardingCompleteSettledNonApproved(status: ScreenTimeAuthorizationStatus) { let destination = RootDestination.resolve( hasCompletedOnboarding: true, authorizationStatus: status, - hasReceivedAuthorizationStatus: true + hasCompletedLaunchSettle: true ) #expect(destination == .screenTimeAccessRequired) } diff --git a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift index 0d13490..199c003 100644 --- a/OpenAppLockTests/ScreenTimeAuthorizationTests.swift +++ b/OpenAppLockTests/ScreenTimeAuthorizationTests.swift @@ -10,14 +10,19 @@ import Testing @MainActor @Suite("Screen Time authorization observation") struct ScreenTimeAuthorizationTests { - @Test("Before the stream posts, no status has been received") - func noStatusReceivedBeforeStreamPosts() { + @Test("Status starts .notDetermined until the observed stream posts a value") + func statusStartsNotDetermined() { let auth = ScreenTimeAuthorization(provider: MockAuthorizationProvider(status: .approved)) - #expect(!auth.hasReceivedStatus) + #expect(auth.status == .notDetermined) } - @Test("Observing the stream delivers approved and marks the status received") - func observationDeliversApproved() async { + @Test( + """ + Draining the stream lands on its final value, so a transient launch-time \ + .notDetermined followed by the real .approved resolves to .approved + """ + ) + func observationResolvesTransientNotDeterminedToApproved() async { let provider = MockAuthorizationProvider( status: .notDetermined, scriptedUpdates: [.notDetermined, .approved] @@ -27,25 +32,18 @@ struct ScreenTimeAuthorizationTests { await auth.observeStatusUpdates() #expect(auth.status == .approved) - #expect(auth.hasReceivedStatus) } - @Test( - """ - A .notDetermined value from the stream is decisive: it is marked received \ - (so the root routes to access-required), not treated as still pending - """ - ) - func observationDeliversNotDeterminedAsDecisive() async { + @Test("Draining a stream whose final value is .notDetermined leaves status .notDetermined") + func observationResolvesToNotDetermined() async { let provider = MockAuthorizationProvider( status: .notDetermined, - scriptedUpdates: [.notDetermined] + scriptedUpdates: [.approved, .notDetermined] ) let auth = ScreenTimeAuthorization(provider: provider) await auth.observeStatusUpdates() #expect(auth.status == .notDetermined) - #expect(auth.hasReceivedStatus) } } From c2b82540f5c714c9ab685056252685c3ae2aaa14 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 21 Jul 2026 19:55:31 -0400 Subject: [PATCH 5/5] Add animation to launch settle and reduce default delay --- OpenAppLock/Views/RootView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/OpenAppLock/Views/RootView.swift b/OpenAppLock/Views/RootView.swift index d7e44e2..d2d6c5f 100644 --- a/OpenAppLock/Views/RootView.swift +++ b/OpenAppLock/Views/RootView.swift @@ -14,7 +14,7 @@ import SwiftUI 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(750) + static let defaultLaunchSettleDelay: Duration = .milliseconds(250) var launchSettleDelay: Duration = RootView.defaultLaunchSettleDelay @@ -69,7 +69,9 @@ struct RootView: View { // launch, then commit to whatever it resolved to (see `RootDestination`). .task { try? await Task.sleep(for: launchSettleDelay) - hasCompletedLaunchSettle = true + withAnimation { + hasCompletedLaunchSettle = true + } } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return }