diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index fdbe02ac8653..dae35374ad3d 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -26113,70 +26113,6 @@ "surface": "apple", "id": "native.apple.1a8232361f3d72fb" }, - { - "kind": "ui-named-argument", - "line": 14, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Allow access", - "surface": "apple", - "id": "native.apple.9dcd64ab340a6c15" - }, - { - "kind": "ui-named-argument", - "line": 15, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Choose what your agent can use on this iPhone. Nothing is on until you allow it.", - "surface": "apple", - "id": "native.apple.933a2ac28d1d2aec" - }, - { - "kind": "ui-call", - "line": 31, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "You can change any of these later in Settings.", - "surface": "apple", - "id": "native.apple.47114a39f7a71c3b" - }, - { - "kind": "ui-call", - "line": 43, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Continue", - "surface": "apple", - "id": "native.apple.eabf54e2b640a4a1" - }, - { - "kind": "ui-localized-call", - "line": 70, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Limited", - "surface": "apple", - "id": "native.apple.5f425282ece878eb" - }, - { - "kind": "ui-localized-call", - "line": 78, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Allow", - "surface": "apple", - "id": "native.apple.c5db9e6eae115f7a" - }, - { - "kind": "ui-localized-call", - "line": 80, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Open Settings", - "surface": "apple", - "id": "native.apple.f591e16105268e99" - }, - { - "kind": "ui-localized-call", - "line": 82, - "path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift", - "source": "Manage", - "surface": "apple", - "id": "native.apple.12ed416835c36555" - }, { "kind": "conditional-branch", "line": 11, @@ -26595,7 +26531,7 @@ }, { "kind": "ui-modifier", - "line": 193, + "line": 191, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -26603,7 +26539,7 @@ }, { "kind": "ui-call", - "line": 199, + "line": 197, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OK", "surface": "apple", @@ -26611,7 +26547,7 @@ }, { "kind": "ui-call", - "line": 304, + "line": 302, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan Setup Code", "surface": "apple", @@ -26619,7 +26555,7 @@ }, { "kind": "ui-call", - "line": 312, + "line": 310, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Cancel", "surface": "apple", @@ -26627,7 +26563,7 @@ }, { "kind": "ui-call", - "line": 319, + "line": 317, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Photos", "surface": "apple", @@ -26635,7 +26571,7 @@ }, { "kind": "ui-modifier", - "line": 360, + "line": 358, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back", "surface": "apple", @@ -26643,7 +26579,7 @@ }, { "kind": "ui-call", - "line": 368, + "line": 366, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Close", "surface": "apple", @@ -26651,7 +26587,7 @@ }, { "kind": "ui-modifier", - "line": 394, + "line": 392, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Dismiss Keyboard", "surface": "apple", @@ -26659,7 +26595,7 @@ }, { "kind": "ui-call", - "line": 449, + "line": 443, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Mode", "surface": "apple", @@ -26667,7 +26603,7 @@ }, { "kind": "ui-call", - "line": 450, + "line": 444, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery", "surface": "apple", @@ -26675,7 +26611,7 @@ }, { "kind": "ui-call", - "line": 452, + "line": 446, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Status", "surface": "apple", @@ -26683,7 +26619,7 @@ }, { "kind": "ui-call", - "line": 470, + "line": 464, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Choose a mode first.", "surface": "apple", @@ -26691,7 +26627,7 @@ }, { "kind": "ui-call", - "line": 475, + "line": 469, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back to Mode Selection", "surface": "apple", @@ -26699,7 +26635,7 @@ }, { "kind": "ui-named-argument", - "line": 519, + "line": 513, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -26707,7 +26643,7 @@ }, { "kind": "ui-named-argument", - "line": 523, + "line": 517, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -26715,7 +26651,7 @@ }, { "kind": "ui-call", - "line": 534, + "line": 528, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -26723,7 +26659,7 @@ }, { "kind": "ui-call", - "line": 537, + "line": 531, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -26731,7 +26667,7 @@ }, { "kind": "ui-call", - "line": 565, + "line": 559, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh setup code or update token/password.", "surface": "apple", @@ -26739,7 +26675,7 @@ }, { "kind": "ui-call", - "line": 573, + "line": 567, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OpenClaw is checking gateway and node access.", "surface": "apple", @@ -26747,7 +26683,7 @@ }, { "kind": "ui-call", - "line": 587, + "line": 581, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -26755,7 +26691,7 @@ }, { "kind": "ui-call", - "line": 593, + "line": 587, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -26763,7 +26699,7 @@ }, { "kind": "ui-localized-call", - "line": 599, + "line": 593, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Request ID: %@", "surface": "apple", @@ -26771,7 +26707,7 @@ }, { "kind": "ui-localized-call", - "line": 602, + "line": 596, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Request ID: check `openclaw devices list`.", "surface": "apple", @@ -26779,7 +26715,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 606, + "line": 600, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `%1$@`\n2) `/pair approve` in your OpenClaw chat\n%2$@\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -26787,7 +26723,7 @@ }, { "kind": "ui-call", - "line": 623, + "line": 617, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan Setup Code Again", "surface": "apple", @@ -26795,7 +26731,7 @@ }, { "kind": "ui-call", - "line": 636, + "line": 630, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -26803,7 +26739,7 @@ }, { "kind": "ui-call", - "line": 667, + "line": 661, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Enter setup code", "surface": "apple", @@ -26811,7 +26747,7 @@ }, { "kind": "ui-call", - "line": 683, + "line": 677, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply", "surface": "apple", @@ -26819,7 +26755,7 @@ }, { "kind": "ui-call", - "line": 701, + "line": 695, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -26827,7 +26763,7 @@ }, { "kind": "ui-call", - "line": 704, + "line": 698, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you have a setup code instead of scanning.", "surface": "apple", @@ -26835,7 +26771,7 @@ }, { "kind": "ui-call", - "line": 716, + "line": 710, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -26843,7 +26779,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 711, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -26851,7 +26787,7 @@ }, { "kind": "ui-call", - "line": 720, + "line": 714, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -26859,7 +26795,7 @@ }, { "kind": "ui-call", - "line": 725, + "line": 719, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -26867,7 +26803,7 @@ }, { "kind": "ui-call", - "line": 729, + "line": 723, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -26875,7 +26811,7 @@ }, { "kind": "ui-call", - "line": 759, + "line": 753, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Unencrypted", "surface": "apple", @@ -26883,7 +26819,7 @@ }, { "kind": "ui-call", - "line": 762, + "line": 756, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Secure (TLS)", "surface": "apple", @@ -26891,7 +26827,7 @@ }, { "kind": "ui-call", - "line": 766, + "line": 760, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connection security", "surface": "apple", @@ -26899,7 +26835,7 @@ }, { "kind": "ui-call", - "line": 836, + "line": 830, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", @@ -26907,7 +26843,7 @@ }, { "kind": "ui-call", - "line": 840, + "line": 834, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connect", "surface": "apple", @@ -26947,31 +26883,7 @@ }, { "kind": "ui-localized-call", - "line": 60, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Notifications", - "surface": "apple", - "id": "native.apple.96d65a5563690719" - }, - { - "kind": "ui-localized-call", - "line": 61, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Camera", - "surface": "apple", - "id": "native.apple.d290a46515add091" - }, - { - "kind": "ui-localized-call", - "line": 62, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Microphone", - "surface": "apple", - "id": "native.apple.0eeedbc8aaafcd2c" - }, - { - "kind": "ui-localized-call", - "line": 63, + "line": 41, "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", "source": "Photos", "surface": "apple", @@ -26979,7 +26891,7 @@ }, { "kind": "ui-localized-call", - "line": 64, + "line": 42, "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", "source": "Contacts", "surface": "apple", @@ -26987,7 +26899,7 @@ }, { "kind": "ui-localized-call", - "line": 65, + "line": 43, "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", "source": "Calendar", "surface": "apple", @@ -26995,84 +26907,12 @@ }, { "kind": "ui-localized-call", - "line": 66, + "line": 44, "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", "source": "Reminders", "surface": "apple", "id": "native.apple.9ed5c19cf1e84490" }, - { - "kind": "ui-localized-call", - "line": 67, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Location", - "surface": "apple", - "id": "native.apple.866b9df8afcadf07" - }, - { - "kind": "ui-localized-call", - "line": 73, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Pairing approvals and replies from your agent.", - "surface": "apple", - "id": "native.apple.f205ac6778705a7d" - }, - { - "kind": "ui-localized-call", - "line": 74, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Scan setup codes and take photos on request.", - "surface": "apple", - "id": "native.apple.061a17077ee3be94" - }, - { - "kind": "ui-localized-call", - "line": 75, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Talk with your agent using voice.", - "surface": "apple", - "id": "native.apple.c735ac6b282765f9" - }, - { - "kind": "ui-localized-call", - "line": 76, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Share recent photos when you ask.", - "surface": "apple", - "id": "native.apple.ebe8aadcf512ecdb" - }, - { - "kind": "ui-localized-call", - "line": 77, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Look up and add people you mention.", - "surface": "apple", - "id": "native.apple.4c4bc12c0787383c" - }, - { - "kind": "ui-localized-call", - "line": 78, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Check and add calendar events.", - "surface": "apple", - "id": "native.apple.5a4006fbb9b23db1" - }, - { - "kind": "ui-localized-call", - "line": 79, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "List, add, and complete reminders.", - "surface": "apple", - "id": "native.apple.2b2a4f1aab6a12ec" - }, - { - "kind": "ui-localized-call", - "line": 80, - "path": "apps/ios/Sources/Permissions/DevicePermissions.swift", - "source": "Answer location and nearby questions.", - "surface": "apple", - "id": "native.apple.3854ae1f0606bafe" - }, { "kind": "conditional-branch", "line": 490, @@ -27685,9 +27525,9 @@ "kind": "ui-localized-call", "line": 169, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", - "source": "Allow", + "source": "Continue", "surface": "apple", - "id": "native.apple.0b10201d7e452dc4" + "id": "native.apple.856d1960275796ef" }, { "kind": "ui-localized-call", diff --git a/apps/ios/APP-REVIEW-NOTES.md b/apps/ios/APP-REVIEW-NOTES.md index d66faf2a609a..25a10748a9a9 100644 --- a/apps/ios/APP-REVIEW-NOTES.md +++ b/apps/ios/APP-REVIEW-NOTES.md @@ -169,6 +169,12 @@ The push wake and node status demo completed. ## Device Permissions +OpenClaw does not present an aggregate permissions page during onboarding. +Optional access is requested when the reviewer invokes the related feature or +taps `Continue` for a permission in Settings. `Continue` immediately presents +the native iOS authorization prompt, with no additional OpenClaw dialog or +deferral action. + 1. Tap `Settings`. 2. Tap `Permissions`. 3. Confirm these current app controls are available: @@ -177,11 +183,19 @@ The push wake and node status demo completed. - `Keep Awake` 4. Confirm the always-visible `Apple Health` section identifies the integration as `Apple Health Summaries`. -5. Expand `Privacy & Access` and confirm these request controls are available: - - `Contacts` / `Request Access` - - `Calendar (Add Events)` / `Request Access` - - `Calendar (View Events)` / `Request Full Access` - - `Reminders` / `Request Access` +5. Expand `Privacy & Access` and confirm these controls show `Continue` when + access has not previously been requested: + - `Contacts` + - `Photos` + - `Calendar (Add Events)` + - `Calendar (View Events)` + - `Reminders` +6. Tap `Continue` for any permission that has not been requested. Confirm the + corresponding native iOS authorization prompt appears immediately, then + make the permission choice in that system prompt. + +If the review device already has a decision for a permission, the row shows +its current status or a management action instead of `Continue`. ### Optional Apple Health summary diff --git a/apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift b/apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift deleted file mode 100644 index dd650f64743e..000000000000 --- a/apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift +++ /dev/null @@ -1,103 +0,0 @@ -import SwiftUI - -/// First-run permissions page: one tap per grant, live status, always skippable. -/// System prompts fire only from the row actions; product opt-ins stay in Settings. -struct OnboardingPermissionsStep: View { - @Environment(\.scenePhase) private var scenePhase - @State private var permissions = DevicePermissionsModel() - let onContinue: () -> Void - - var body: some View { - OnboardingActivationCanvas { - VStack(alignment: .leading, spacing: 0) { - OnboardingHeroHeader( - title: "Allow access", - subtitle: "Choose what your agent can use on this iPhone. Nothing is on until you allow it.") - .padding(.top, 18) - - OnboardingIntroPanel { - VStack(alignment: .leading, spacing: 2) { - ForEach(DevicePermissionKind.allCases) { kind in - self.row(for: kind) - if kind != DevicePermissionKind.allCases.last { - Divider() - .padding(.leading, 48) - } - } - } - } - .padding(.top, 30) - - Text("You can change any of these later in Settings.") - .font(OpenClawType.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - .padding(.top, 14) - - Spacer(minLength: 32) - - Button { - self.onContinue() - } label: { - Text("Continue") - .font(OpenClawType.subheadSemiBold) - } - .buttonStyle(OpenClawPrimaryActionButtonStyle()) - .padding(.top, 22) - } - } - .task { - await self.permissions.refresh() - } - .onChange(of: self.scenePhase) { _, phase in - guard phase == .active else { return } - Task { await self.permissions.refresh() } - } - } - - private func row(for kind: DevicePermissionKind) -> some View { - let grant = self.permissions.grant(for: kind) - return DevicePermissionRow( - identifierPrefix: "onboarding-permission", - identifier: kind.rawValue, - symbol: kind.symbol, - tint: kind.tint, - title: kind.title, - detail: kind.detail, - grant: grant, - isRequesting: self.permissions.requesting.contains(kind), - statusLabel: grant == .limited ? LocalizedStringResource("Limited") : nil, - actionTitle: Self.actionTitle(for: grant), - action: self.action(for: kind, grant: grant)) - } - - private static func actionTitle(for grant: DevicePermissionGrant) -> LocalizedStringResource? { - switch grant { - case .notRequested: - LocalizedStringResource("Allow") - case .denied: - LocalizedStringResource("Open Settings") - case .limited: - LocalizedStringResource("Manage") - case .granted: - nil - } - } - - private func action(for kind: DevicePermissionKind, grant: DevicePermissionGrant) -> (() -> Void)? { - switch grant { - case .notRequested: - { Task { await self.permissions.request(kind) } } - case .denied, .limited: - { self.openSystemSettings() } - case .granted: - nil - } - } - - private func openSystemSettings() { - guard let url = URL(string: UIApplication.openSettingsURLString) else { return } - UIApplication.shared.open(url) - } -} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift b/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift index e1a0f26c109a..5e9cb7cf999a 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardTypes.swift @@ -3,7 +3,6 @@ import SwiftUI enum OnboardingStep: Int, CaseIterable { case intro - case permissions case welcome case mode case connect @@ -24,7 +23,6 @@ enum OnboardingStep: Int, CaseIterable { var title: LocalizedStringKey { switch self { case .intro: "Welcome" - case .permissions: "Permissions" case .welcome: "Connect Gateway" case .mode: "Gateway Setup" case .connect: "Gateway Details" @@ -35,7 +33,7 @@ enum OnboardingStep: Int, CaseIterable { var canGoBack: Bool { switch self { - case .intro, .permissions, .welcome, .success: + case .intro, .welcome, .success: false case .mode, .connect, .auth: true diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index aaed424622a1..28feb30f3ba5 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -74,7 +74,7 @@ struct OnboardingWizardView: View { } private var isFullScreenStep: Bool { - self.step == .intro || self.step == .permissions || self.step == .welcome || self.step == .success + self.step == .intro || self.step == .welcome || self.step == .success } private var currentProblem: GatewayConnectionProblem? { @@ -139,8 +139,6 @@ struct OnboardingWizardView: View { switch self.step { case .intro: self.introStep - case .permissions: - self.permissionsStep case .welcome: self.welcomeStep case .success: @@ -403,10 +401,6 @@ struct OnboardingWizardView: View { OnboardingIntroStep(onContinue: self.advanceFromIntro) } - private var permissionsStep: some View { - OnboardingPermissionsStep(onContinue: self.advanceFromPermissions) - } - private var welcomeStep: some View { OnboardingWelcomeStep( statusLine: self.statusLine, @@ -1148,13 +1142,7 @@ extension OnboardingWizardView { } private func advanceFromIntro() { - self.statusLine = "" - self.navigate(to: .permissions) - } - - private func advanceFromPermissions() { - // Marked here, not on the intro Continue: an interrupted first run must - // replay intro + permissions on relaunch instead of skipping them forever. + // An interrupted first run replays the intro until the user explicitly continues. OnboardingStateStore.markFirstRunIntroSeen() self.requestLocalNetworkAccess(reason: "onboarding_continue") self.statusLine = "" @@ -1162,9 +1150,8 @@ extension OnboardingWizardView { } private func requestLocalNetworkAccessIfPastIntro(reason: String) { - // The local-network prompt waits until pairing starts so it never stacks - // on top of the permission prompts users trigger on the permissions step. - guard self.step != .intro, self.step != .permissions else { return } + // Keep the first-run intro focused; request local-network access when pairing starts. + guard self.step != .intro else { return } self.requestLocalNetworkAccess(reason: reason) } diff --git a/apps/ios/Sources/Permissions/DevicePermissionRow.swift b/apps/ios/Sources/Permissions/DevicePermissionRow.swift index f6657a7f37fb..9a43f8680946 100644 --- a/apps/ios/Sources/Permissions/DevicePermissionRow.swift +++ b/apps/ios/Sources/Permissions/DevicePermissionRow.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Capsule action used by permission rows: filled for the initial "Allow", +/// Capsule action used by permission rows: filled for the initial "Continue", /// bordered for repair actions like "Open Settings" or "Upgrade". struct DevicePermissionActionButtonStyle: ButtonStyle { let prominent: Bool @@ -38,7 +38,7 @@ struct DevicePermissionActionButtonStyle: ButtonStyle { } /// One device permission with an icon tile, explanation, and a single clear -/// affordance: Allow when unset, a green check when granted, a repair action otherwise. +/// affordance: Continue when unset, a green check when granted, a repair action otherwise. struct DevicePermissionRow: View { let identifierPrefix: String let identifier: String diff --git a/apps/ios/Sources/Permissions/DevicePermissions.swift b/apps/ios/Sources/Permissions/DevicePermissions.swift index 26a0285a2cd5..145e3c499019 100644 --- a/apps/ios/Sources/Permissions/DevicePermissions.swift +++ b/apps/ios/Sources/Permissions/DevicePermissions.swift @@ -1,12 +1,9 @@ -import AVFoundation import Contacts -import CoreLocation import EventKit import Photos import SwiftUI -import UserNotifications -/// Closed grant state for one device permission, shared by onboarding and Settings rows. +/// Closed grant state for one device permission shown in Settings. enum DevicePermissionGrant: Equatable { case granted case limited @@ -14,75 +11,42 @@ enum DevicePermissionGrant: Equatable { case denied } -/// Device permissions the connected agent can use from this iPhone. -enum DevicePermissionKind: String, CaseIterable, Identifiable { - case notifications - case camera - case microphone +/// Device permissions managed by the Privacy & Access Settings section. +enum DevicePermissionKind { case photos case contacts case calendar case reminders - case location - - var id: String { - self.rawValue - } var symbol: String { switch self { - case .notifications: "bell.badge.fill" - case .camera: "camera.fill" - case .microphone: "mic.fill" case .photos: "photo.on.rectangle" case .contacts: "person.crop.circle.fill" case .calendar: "calendar" case .reminders: "checklist" - case .location: "location.fill" } } var tint: Color { switch self { - case .notifications: .red - case .camera: .indigo - case .microphone: .pink case .photos: .orange case .contacts: .blue case .calendar: .teal case .reminders: .green - case .location: .purple } } var title: LocalizedStringResource { switch self { - case .notifications: LocalizedStringResource("Notifications") - case .camera: LocalizedStringResource("Camera") - case .microphone: LocalizedStringResource("Microphone") case .photos: LocalizedStringResource("Photos") case .contacts: LocalizedStringResource("Contacts") case .calendar: LocalizedStringResource("Calendar") case .reminders: LocalizedStringResource("Reminders") - case .location: LocalizedStringResource("Location") - } - } - - var detail: LocalizedStringResource { - switch self { - case .notifications: LocalizedStringResource("Pairing approvals and replies from your agent.") - case .camera: LocalizedStringResource("Scan setup codes and take photos on request.") - case .microphone: LocalizedStringResource("Talk with your agent using voice.") - case .photos: LocalizedStringResource("Share recent photos when you ask.") - case .contacts: LocalizedStringResource("Look up and add people you mention.") - case .calendar: LocalizedStringResource("Check and add calendar events.") - case .reminders: LocalizedStringResource("List, add, and complete reminders.") - case .location: LocalizedStringResource("Answer location and nearby questions.") } } } -/// Pure status→grant maps so onboarding and Settings agree on one vocabulary. +/// Pure status→grant maps keep every Settings row on one vocabulary. enum DevicePermissionStatusMap { static func contacts(_ status: CNAuthorizationStatus) -> DevicePermissionGrant { switch status { @@ -124,117 +88,4 @@ enum DevicePermissionStatusMap { @unknown default: .denied } } - - static func capture(_ status: AVAuthorizationStatus) -> DevicePermissionGrant { - switch status { - case .authorized: .granted - case .notDetermined: .notRequested - case .denied, .restricted: .denied - @unknown default: .denied - } - } - - static func microphone(_ permission: AVAudioApplication.recordPermission) -> DevicePermissionGrant { - switch permission { - case .granted: .granted - case .undetermined: .notRequested - case .denied: .denied - @unknown default: .denied - } - } - - static func notifications(_ status: UNAuthorizationStatus) -> DevicePermissionGrant { - switch status { - case .authorized, .ephemeral, .provisional: .granted - case .notDetermined: .notRequested - case .denied: .denied - @unknown default: .denied - } - } - - static func location(_ status: CLAuthorizationStatus) -> DevicePermissionGrant { - switch status { - case .authorizedAlways, .authorizedWhenInUse: .granted - case .notDetermined: .notRequested - case .denied, .restricted: .denied - @unknown default: .denied - } - } -} - -/// Live grant state + request plumbing for the onboarding permissions step. -/// Requests only raise the system prompts; product opt-ins (push relay serving, -/// Health sharing) remain explicit choices in Settings. -@MainActor -@Observable -final class DevicePermissionsModel { - private(set) var grants: [DevicePermissionKind: DevicePermissionGrant] = [:] - private(set) var requesting: Set = [] - private let eventKitPermissions: EventKitPermissionRequester - - /// Owns its manager so authorization callbacks resolve without the app-wide service. - private let locationService = LocationService() - - init(eventKitPermissions: EventKitPermissionRequester = EventKitPermissionRequester()) { - self.eventKitPermissions = eventKitPermissions - self.locationService.setAuthorizationChangeHandler { [weak self] snapshot in - self?.grants[.location] = DevicePermissionStatusMap.location(snapshot.authorizationStatus) - } - } - - func grant(for kind: DevicePermissionKind) -> DevicePermissionGrant { - self.grants[kind] ?? .notRequested - } - - func refresh() async { - var next: [DevicePermissionKind: DevicePermissionGrant] = [ - .camera: DevicePermissionStatusMap.capture(AVCaptureDevice.authorizationStatus(for: .video)), - .microphone: DevicePermissionStatusMap.microphone(AVAudioApplication.shared.recordPermission), - .photos: DevicePermissionStatusMap.photos(PhotoLibraryAccess.authorizationStatus()), - .contacts: DevicePermissionStatusMap.contacts(CNContactStore.authorizationStatus(for: .contacts)), - .calendar: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .event)), - .reminders: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .reminder)), - .location: DevicePermissionStatusMap.location(self.locationService.authorizationStatus()), - ] - let settings = await UNUserNotificationCenter.current().notificationSettings() - next[.notifications] = DevicePermissionStatusMap.notifications(settings.authorizationStatus) - self.grants = next - } - - func request(_ kind: DevicePermissionKind) async { - guard self.grant(for: kind) == .notRequested, !self.requesting.contains(kind) else { return } - self.requesting.insert(kind) - defer { self.requesting.remove(kind) } - - switch kind { - case .notifications: - _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [ - .alert, - .badge, - .sound, - ]) - case .camera: - _ = await PermissionRequestBridge.awaitRequest { completion in - AVCaptureDevice.requestAccess(for: .video, completionHandler: completion) - } - case .microphone: - _ = await TalkModeManager.requestMicrophonePermission() - case .photos: - _ = await PhotoLibraryAccess.requestReadWrite() - case .contacts: - _ = await PermissionRequestBridge.awaitRequest { completion in - CNContactStore().requestAccess(for: .contacts) { granted, _ in - completion(granted) - } - } - case .calendar: - _ = await self.eventKitPermissions.requestFullAccessToEvents() - case .reminders: - _ = await self.eventKitPermissions.requestFullAccessToReminders() - case .location: - _ = await self.locationService.ensureAuthorization(mode: .whileUsing) - } - - await self.refresh() - } } diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift index 9cfd2accf2ae..6119ddf61f31 100644 --- a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift +++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift @@ -166,7 +166,7 @@ struct PrivacyAccessSectionView: View { { switch grant { case .notRequested: - LocalizedStringResource("Allow") + LocalizedStringResource("Continue") case .denied: LocalizedStringResource("Open Settings") case .limited: diff --git a/apps/ios/Tests/DevicePermissionsTests.swift b/apps/ios/Tests/DevicePermissionsTests.swift index b8124a7ad6c1..d92afa69d104 100644 --- a/apps/ios/Tests/DevicePermissionsTests.swift +++ b/apps/ios/Tests/DevicePermissionsTests.swift @@ -1,9 +1,8 @@ -import AVFoundation import Contacts import EventKit +import Foundation import Photos import Testing -import UserNotifications @testable import OpenClaw struct DevicePermissionsTests { @@ -31,31 +30,58 @@ struct DevicePermissionsTests { #expect(DevicePermissionStatusMap.eventKitRead(.denied) == .denied) } - @Test func `capture microphone notification and location statuses map to shared grants`() { - #expect(DevicePermissionStatusMap.capture(.authorized) == .granted) - #expect(DevicePermissionStatusMap.capture(.notDetermined) == .notRequested) - #expect(DevicePermissionStatusMap.capture(.denied) == .denied) - - #expect(DevicePermissionStatusMap.microphone(.granted) == .granted) - #expect(DevicePermissionStatusMap.microphone(.undetermined) == .notRequested) - #expect(DevicePermissionStatusMap.microphone(.denied) == .denied) - - #expect(DevicePermissionStatusMap.notifications(.authorized) == .granted) - #expect(DevicePermissionStatusMap.notifications(.provisional) == .granted) - #expect(DevicePermissionStatusMap.notifications(.notDetermined) == .notRequested) - #expect(DevicePermissionStatusMap.notifications(.denied) == .denied) - - #expect(DevicePermissionStatusMap.location(.authorizedWhenInUse) == .granted) - #expect(DevicePermissionStatusMap.location(.authorizedAlways) == .granted) - #expect(DevicePermissionStatusMap.location(.notDetermined) == .notRequested) - #expect(DevicePermissionStatusMap.location(.restricted) == .denied) + @Test func `first-run onboarding moves directly from intro to pairing`() { + #expect(OnboardingStep.intro.previous == nil) + #expect(OnboardingStep.welcome.previous == .intro) + #expect(!OnboardingStep.welcome.canGoBack) + #expect(OnboardingStep.welcome.manualProgressTitle.isEmpty) } - @Test func `first-run onboarding inserts permissions between intro and pairing`() { - #expect(OnboardingStep.permissions.previous == .intro) - #expect(OnboardingStep.welcome.previous == .permissions) - #expect(!OnboardingStep.permissions.canGoBack) - // Permissions is a first-run page, not part of the manual connect progress trail. - #expect(OnboardingStep.permissions.manualProgressTitle.isEmpty) + @Test func `onboarding has no aggregate system permission prompt`() throws { + let onboardingDirectory = Self.sourceRoot() + .appending(path: "Onboarding", directoryHint: .isDirectory) + let wizard = try String( + contentsOf: onboardingDirectory.appending(path: "OnboardingWizardView.swift"), + encoding: .utf8) + + #expect(!FileManager.default.fileExists( + atPath: onboardingDirectory.appending(path: "OnboardingPermissionsStep.swift").path)) + #expect(!wizard.contains("OnboardingPermissionsStep")) + #expect(!wizard.contains("navigate(to: .permissions)")) + } + + @Test func `settings first request says Continue and immediately requests permission`() throws { + let settings = try String( + contentsOf: Self.sourceRoot() + .appending(path: "Settings", directoryHint: .isDirectory) + .appending(path: "PrivacyAccessSectionView.swift"), + encoding: .utf8) + let actionTitles = try #require(Self.extract( + settings, + from: " private func standardActionTitle(", + to: " /// `limitedRequests`")) + let action = try #require(Self.extract( + settings, + from: " private func standardAction(", + to: " private func requestContacts()")) + + #expect(actionTitles.contains("case .notRequested:\n LocalizedStringResource(\"Continue\")")) + #expect(!actionTitles.contains("LocalizedStringResource(\"Allow\")")) + #expect(action.contains("await request()")) + #expect(action.contains("case .notRequested:\n return run")) + } + + private static func sourceRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "Sources", directoryHint: .isDirectory) + } + + private static func extract(_ source: String, from start: String, to end: String) -> String? { + guard let startRange = source.range(of: start), + let endRange = source.range(of: end, range: startRange.upperBound.. **Permissions**, or in the iOS Settings app. +On first launch the app walks through a short pairing explainer, then Gateway +setup. It does not present an aggregate permissions page. Optional access is +requested when you use the related feature, or after you tap **Continue** for +that permission under **Settings** -> **Permissions** -> **Privacy & Access**. +**Continue** immediately presents the native iOS authorization prompt. You can +change granted access later in the iOS Settings app. 1. Start an authenticated Gateway with a route your phone can reach. Tailscale Serve is the recommended remote path: