fix: settle Screen Time authorization behind a launch screen before gating - #57
Merged
Merged
Conversation
…tter 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 <noreply@anthropic.com>
…e source of truth .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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…auth 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Screen Time gate misbehaves at cold launch. Two compounding issues:
AuthorizationCenter.shared.authorizationStatusgetter, which can stay pinned at.notDeterminedindefinitely (confirmed on device)..notDeterminedbefore the real value (even for an approved user) — and there's no reliable way to tell that transient apart from the decisive.notDeterminedthe system reports when Screen Time is turned off in Settings.Fix
Observe the stream as the source of truth, and stop trying to interpret a launch-time
.notDeterminedin the moment — instead hold a launch screen for a fixed window so the stream can settle, then commit.AuthorizationProvidingexposes onlystatusUpdates: AsyncStream<ScreenTimeAuthorizationStatus>(the synchronous getter is gone).FamilyControlsAuthorizationProviderimplements it overAuthorizationCenter.shared.$authorizationStatus.values.ScreenTimeAuthorization.startObserving()drains that stream intostatusfor the app's lifetime (observeStatusUpdates()split out so tests await it deterministically).RootDestination.resolvegains a.launchSettlingcase: once onboarding is complete, route there untilhasCompletedLaunchSettle; then.approved→.mainand every other status (incl..notDetermined) →.screenTimeAccessRequired.RootViewholdsLaunchScreenViewfor a fixedlaunchSettleDelay(default 750 ms) via a.task, then flips the flag.LaunchScreenViewis a SwiftUI replica of the launch screen — iOS can't hold the real launch screen past the first frame, so a matching replica is the standard way to "extend" it. It currently renders the blanksystemBackground(matching today's blank launch screen); keep it in sync when a real launch screen is added.OpenAppLockApppasses.zerodelay under UI testing so the suite isn't slowed.Also removed the hardcoded
DEVELOPMENT_TEAMID from the project (kept only in local working copies for signing).Test plan
RootDestinationTests— onboarding incomplete → onboarding; complete-but-not-settled →.launchSettling(any status); settled +.approved→.main; settled +.notDetermined/.denied→.screenTimeAccessRequired.ScreenTimeAuthorizationTests— status starts.notDetermineduntil the stream posts; draining lands on the stream's final value (transient.notDetermined→.approvedresolves to.approved; final.notDeterminedstays.notDetermined).LaunchSupportTests(request()→ approved / denied) still green.🤖 Generated with Claude Code