Skip to content

fix: settle Screen Time authorization behind a launch screen before gating - #57

Merged
brendan-ch merged 5 commits into
mainfrom
feat/observe-screen-time-authorization-status
Jul 21, 2026
Merged

fix: settle Screen Time authorization behind a launch screen before gating#57
brendan-ch merged 5 commits into
mainfrom
feat/observe-screen-time-authorization-status

Conversation

@brendan-ch

@brendan-ch brendan-ch commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Problem

The Screen Time gate misbehaves at cold launch. Two compounding issues:

  1. State was driven by the synchronous AuthorizationCenter.shared.authorizationStatus getter, which can stay pinned at .notDetermined indefinitely (confirmed on device).
  2. Even when observing the async stream, on a cold launch it can emit a transient .notDetermined before the real value (even for an approved user) — and there's no reliable way to tell that transient apart from the decisive .notDetermined the 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 .notDetermined in the moment — instead hold a launch screen for a fixed window so the stream can settle, then commit.

  • AuthorizationProviding exposes only statusUpdates: AsyncStream<ScreenTimeAuthorizationStatus> (the synchronous getter is gone). FamilyControlsAuthorizationProvider implements it over AuthorizationCenter.shared.$authorizationStatus.values.
  • ScreenTimeAuthorization.startObserving() drains that stream into status for the app's lifetime (observeStatusUpdates() split out so tests await it deterministically).
  • RootDestination.resolve gains a .launchSettling case: once onboarding is complete, route there until hasCompletedLaunchSettle; then .approved.main and every other status (incl. .notDetermined) → .screenTimeAccessRequired.
  • RootView holds LaunchScreenView for a fixed launchSettleDelay (default 750 ms) via a .task, then flips the flag. LaunchScreenView is 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 blank systemBackground (matching today's blank launch screen); keep it in sync when a real launch screen is added.
  • OpenAppLockApp passes .zero delay under UI testing so the suite isn't slowed.

Also removed the hardcoded DEVELOPMENT_TEAM ID 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 .notDetermined until the stream posts; draining lands on the stream's final value (transient .notDetermined.approved resolves to .approved; final .notDetermined stays .notDetermined).
  • LaunchSupportTests (request() → approved / denied) still green.
  • Full unit-test target green: 354 tests in 61 suites passed.
  • On-device verification (the real check — FamilyControls does not run on Simulator): cold-launch with access granted → brief launch screen, then main flow, no flicker; with Screen Time turned off in Settings → brief launch screen, then access-required.

🤖 Generated with Claude Code

brendan-ch and others added 2 commits July 21, 2026 19:09
…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>
@brendan-ch brendan-ch changed the title fix: observe Screen Time authorization instead of polling the stale sync getter fix: drive Screen Time gating from the observed authorization stream Jul 21, 2026
brendan-ch and others added 2 commits July 21, 2026 19:36
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>
@brendan-ch brendan-ch changed the title fix: drive Screen Time gating from the observed authorization stream fix: settle Screen Time authorization behind a launch screen before gating Jul 21, 2026
@brendan-ch
brendan-ch marked this pull request as ready for review July 21, 2026 23:56
@brendan-ch
brendan-ch merged commit c03f16a into main Jul 21, 2026
1 of 2 checks passed
@brendan-ch
brendan-ch deleted the feat/observe-screen-time-authorization-status branch July 21, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant