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.