Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions OpenAppLock.xcodeproj/xcshareddata/xcodecloud/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id" : "10954c04-8bb5-43f2-b337-7231567734e7",
"targets" : [
{
"id" : "B8353488-8E99-45EA-AD5C-394DF3CC2C87",
"name" : "OpenAppLock"
}
]
}
23 changes: 16 additions & 7 deletions OpenAppLock/Logic/RootDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 5 additions & 1 deletion OpenAppLock/OpenAppLockApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -117,7 +121,7 @@ struct OpenAppLockApp: App {

var body: some Scene {
WindowGroup {
RootView()
RootView(launchSettleDelay: launchSettleDelay)
.environment(authorization)
.environment(notificationAuthorization)
.environment(enforcer)
Expand Down
85 changes: 61 additions & 24 deletions OpenAppLock/Services/ScreenTimeAuthorization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// OpenAppLock
//

import Combine
import FamilyControls
import Foundation
import Observation
Expand All @@ -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<ScreenTimeAuthorizationStatus> { 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<ScreenTimeAuthorizationStatus> {
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<ScreenTimeAuthorizationStatus> {
let values = scriptedUpdates ?? [status]
return AsyncStream { continuation in
for value in values { continuation.yield(value) }
continuation.finish()
}
}

func requestAuthorization() async throws {
if requestShouldFail {
Expand All @@ -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<Void, Never>?

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()
}
}
24 changes: 24 additions & 0 deletions OpenAppLock/Views/LaunchScreenView.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
54 changes: 37 additions & 17 deletions OpenAppLock/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() }
}
}
Expand Down
Loading
Loading