From e5f876472050f1351ce6bdc3785ea043d6343576 Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:45:19 -0400 Subject: [PATCH] feat(ios): add Privacy & Access settings section for contacts, calendar, and reminders Apple HIG-aligned permission UX: status rows with contextual Request Access or Open Settings actions, progressive calendar permissioning (write-only vs full access), reminders support, and auto-refresh on return from iOS Settings. --- apps/ios/Sources/Info.plist | 10 + .../Settings/PrivacyAccessSectionView.swift | 303 ++++++++++++++++++ apps/ios/Sources/Settings/SettingsTab.swift | 2 + apps/ios/project.yml | 5 + 4 files changed, 320 insertions(+) create mode 100644 apps/ios/Sources/Settings/PrivacyAccessSectionView.swift diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 5908021fad3..c14e713aa6b 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -50,6 +50,16 @@ NSCameraUsageDescription OpenClaw can capture photos or short video clips when requested via the gateway. + NSCalendarsUsageDescription + OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsFullAccessUsageDescription + OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsWriteOnlyAccessUsageDescription + OpenClaw uses your calendars to add events when you enable calendar access. + NSContactsUsageDescription + OpenClaw uses your contacts so you can search and reference people while using the assistant. + NSRemindersFullAccessUsageDescription + OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access. NSLocalNetworkUsageDescription OpenClaw discovers and connects to your OpenClaw gateway on the local network. NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift new file mode 100644 index 00000000000..8d7951256df --- /dev/null +++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift @@ -0,0 +1,303 @@ +import Contacts +import EventKit +import SwiftUI +import UIKit + +struct PrivacyAccessSectionView: View { + @State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts) + @State private var calendarStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) + @State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder) + + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + DisclosureGroup("Privacy & Access") { + permissionRow( + title: "Contacts", + icon: "person.crop.circle", + status: statusText(for: contactsStatus), + detail: "Search and add contacts from the assistant.", + actionTitle: actionTitle(for: contactsStatus), + action: handleContactsAction + ) + + permissionRow( + title: "Calendar (Add Events)", + icon: "calendar.badge.plus", + status: calendarWriteStatusText, + detail: "Add events with least privilege.", + actionTitle: calendarWriteActionTitle, + action: handleCalendarWriteAction + ) + + permissionRow( + title: "Calendar (View Events)", + icon: "calendar", + status: calendarReadStatusText, + detail: "List and read calendar events.", + actionTitle: calendarReadActionTitle, + action: handleCalendarReadAction + ) + + permissionRow( + title: "Reminders", + icon: "checklist", + status: remindersStatusText, + detail: "List, add, and complete reminders.", + actionTitle: remindersActionTitle, + action: handleRemindersAction + ) + } + .onAppear { refreshAll() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { refreshAll() } + } + } + + // MARK: - Permission Row + + @ViewBuilder + private func permissionRow( + title: String, + icon: String, + status: String, + detail: String, + actionTitle: String?, + action: (() -> Void)? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(title, systemImage: icon) + Spacer() + Text(status) + .font(.footnote.weight(.medium)) + .foregroundStyle(statusColor(for: status)) + } + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + if let actionTitle, let action { + Button(actionTitle, action: action) + .font(.footnote) + .buttonStyle(.bordered) + } + } + .padding(.vertical, 2) + } + + private func statusColor(for status: String) -> Color { + switch status { + case "Allowed": return .green + case "Not Set": return .orange + case "Add-Only": return .yellow + default: return .red + } + } + + // MARK: - Contacts + + private func statusText(for cnStatus: CNAuthorizationStatus) -> String { + switch cnStatus { + case .authorized, .limited: return "Allowed" + case .notDetermined: return "Not Set" + case .denied, .restricted: return "Not Allowed" + @unknown default: return "Unknown" + } + } + + private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? { + switch cnStatus { + case .notDetermined: return "Request Access" + case .denied, .restricted: return "Open Settings" + default: return nil + } + } + + private func handleContactsAction() { + switch contactsStatus { + case .notDetermined: + Task { + let store = CNContactStore() + _ = await withCheckedContinuation { (cont: CheckedContinuation) in + store.requestAccess(for: .contacts) { granted, _ in + cont.resume(returning: granted) + } + } + await MainActor.run { refreshAll() } + } + case .denied, .restricted: + openSettings() + default: + break + } + } + + // MARK: - Calendar Write + + private var calendarWriteStatusText: String { + switch calendarStatus { + case .authorized, .fullAccess, .writeOnly: return "Allowed" + case .notDetermined: return "Not Set" + case .denied, .restricted: return "Not Allowed" + @unknown default: return "Unknown" + } + } + + private var calendarWriteActionTitle: String? { + switch calendarStatus { + case .notDetermined: return "Request Access" + case .denied, .restricted: return "Open Settings" + default: return nil + } + } + + private func handleCalendarWriteAction() { + switch calendarStatus { + case .notDetermined: + Task { + _ = await requestCalendarWriteOnly() + await MainActor.run { refreshAll() } + } + case .denied, .restricted: + openSettings() + default: + break + } + } + + // MARK: - Calendar Read + + private var calendarReadStatusText: String { + switch calendarStatus { + case .authorized, .fullAccess: return "Allowed" + case .writeOnly: return "Add-Only" + case .notDetermined: return "Not Set" + case .denied, .restricted: return "Not Allowed" + @unknown default: return "Unknown" + } + } + + private var calendarReadActionTitle: String? { + switch calendarStatus { + case .notDetermined: return "Request Full Access" + case .writeOnly: return "Upgrade to Full Access" + case .denied, .restricted: return "Open Settings" + default: return nil + } + } + + private func handleCalendarReadAction() { + switch calendarStatus { + case .notDetermined, .writeOnly: + Task { + _ = await requestCalendarFull() + await MainActor.run { refreshAll() } + } + case .denied, .restricted: + openSettings() + default: + break + } + } + + // MARK: - Reminders + + private var remindersStatusText: String { + switch remindersStatus { + case .authorized, .fullAccess: return "Allowed" + case .writeOnly: return "Add-Only" + case .notDetermined: return "Not Set" + case .denied, .restricted: return "Not Allowed" + @unknown default: return "Unknown" + } + } + + private var remindersActionTitle: String? { + switch remindersStatus { + case .notDetermined: return "Request Access" + case .writeOnly: return "Upgrade to Full Access" + case .denied, .restricted: return "Open Settings" + default: return nil + } + } + + private func handleRemindersAction() { + switch remindersStatus { + case .notDetermined: + Task { + _ = await requestRemindersFull() + await MainActor.run { refreshAll() } + } + case .writeOnly: + Task { + _ = await requestRemindersFull() + await MainActor.run { refreshAll() } + } + case .denied, .restricted: + openSettings() + default: + break + } + } + + // MARK: - Helpers + + private func refreshAll() { + contactsStatus = CNContactStore.authorizationStatus(for: .contacts) + calendarStatus = EKEventStore.authorizationStatus(for: .event) + remindersStatus = EKEventStore.authorizationStatus(for: .reminder) + } + + private func requestCalendarWriteOnly() async -> Bool { + let store = EKEventStore() + if #available(iOS 17.0, *) { + return await withCheckedContinuation { cont in + store.requestWriteOnlyAccessToEvents { granted, _ in + cont.resume(returning: granted) + } + } + } + return await withCheckedContinuation { cont in + store.requestAccess(to: .event) { granted, _ in + cont.resume(returning: granted) + } + } + } + + private func requestCalendarFull() async -> Bool { + let store = EKEventStore() + if #available(iOS 17.0, *) { + return await withCheckedContinuation { cont in + store.requestFullAccessToEvents { granted, _ in + cont.resume(returning: granted) + } + } + } + return await withCheckedContinuation { cont in + store.requestAccess(to: .event) { granted, _ in + cont.resume(returning: granted) + } + } + } + + private func requestRemindersFull() async -> Bool { + let store = EKEventStore() + if #available(iOS 17.0, *) { + return await withCheckedContinuation { cont in + store.requestFullAccessToReminders { granted, _ in + cont.resume(returning: granted) + } + } + } + return await withCheckedContinuation { cont in + store.requestAccess(to: .reminder) { granted, _ in + cont.resume(returning: granted) + } + } + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 6df8c1ec510..c97b7dc233c 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -378,6 +378,8 @@ struct SettingsTab: View { } } + PrivacyAccessSectionView() + DisclosureGroup("Device Info") { TextField("Name", text: self.$displayName) Text(self.instanceId) diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 53e6489a25b..af136127383 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -134,6 +134,11 @@ targets: NSBonjourServices: - _openclaw-gw._tcp NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway. + NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access. + NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant. + NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access. NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing. NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.