diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index e90d4e1b7693..3ae96b2dd158 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -27747,7 +27747,7 @@ }, { "kind": "ui-call", - "line": 48, + "line": 49, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Privacy & Access", "surface": "apple", @@ -27755,7 +27755,7 @@ }, { "kind": "ui-localized-call", - "line": 65, + "line": 66, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Search and add contacts from the assistant.", "surface": "apple", @@ -27763,7 +27763,7 @@ }, { "kind": "ui-localized-call", - "line": 80, + "line": 81, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Read photos you select for the assistant.", "surface": "apple", @@ -27771,7 +27771,7 @@ }, { "kind": "ui-localized-call", - "line": 81, + "line": 82, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Read recent photos for the assistant.", "surface": "apple", @@ -27779,7 +27779,7 @@ }, { "kind": "ui-localized-call", - "line": 83, + "line": 84, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Limited", "surface": "apple", @@ -27787,7 +27787,7 @@ }, { "kind": "ui-localized-call", - "line": 96, + "line": 97, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Calendar (Add Events)", "surface": "apple", @@ -27795,7 +27795,7 @@ }, { "kind": "ui-localized-call", - "line": 97, + "line": 98, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Add events with least privilege.", "surface": "apple", @@ -27803,7 +27803,7 @@ }, { "kind": "ui-localized-call", - "line": 111, + "line": 112, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "List and read calendar events.", "surface": "apple", @@ -27811,7 +27811,7 @@ }, { "kind": "ui-localized-call", - "line": 126, + "line": 127, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "List, add, and complete reminders.", "surface": "apple", @@ -27819,7 +27819,7 @@ }, { "kind": "ui-localized-call", - "line": 128, + "line": 129, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Add-Only", "surface": "apple", @@ -27827,7 +27827,7 @@ }, { "kind": "ui-localized-call", - "line": 168, + "line": 169, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Allow", "surface": "apple", @@ -27835,7 +27835,7 @@ }, { "kind": "ui-localized-call", - "line": 170, + "line": 171, "path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift", "source": "Open Settings", "surface": "apple", diff --git a/apps/ios/Sources/Permissions/DevicePermissions.swift b/apps/ios/Sources/Permissions/DevicePermissions.swift index cc6a44282f90..26a0285a2cd5 100644 --- a/apps/ios/Sources/Permissions/DevicePermissions.swift +++ b/apps/ios/Sources/Permissions/DevicePermissions.swift @@ -170,10 +170,13 @@ enum DevicePermissionStatusMap { 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() { + init(eventKitPermissions: EventKitPermissionRequester = EventKitPermissionRequester()) { + self.eventKitPermissions = eventKitPermissions self.locationService.setAuthorizationChangeHandler { [weak self] snapshot in self?.grants[.location] = DevicePermissionStatusMap.location(snapshot.authorizationStatus) } @@ -225,17 +228,9 @@ final class DevicePermissionsModel { } } case .calendar: - _ = await PermissionRequestBridge.awaitRequest { completion in - EKEventStore().requestFullAccessToEvents { granted, _ in - completion(granted) - } - } + _ = await self.eventKitPermissions.requestFullAccessToEvents() case .reminders: - _ = await PermissionRequestBridge.awaitRequest { completion in - EKEventStore().requestFullAccessToReminders { granted, _ in - completion(granted) - } - } + _ = await self.eventKitPermissions.requestFullAccessToReminders() case .location: _ = await self.locationService.ensureAuthorization(mode: .whileUsing) } diff --git a/apps/ios/Sources/Permissions/EventKitPermissionRequester.swift b/apps/ios/Sources/Permissions/EventKitPermissionRequester.swift new file mode 100644 index 000000000000..fe8b31f1e672 --- /dev/null +++ b/apps/ios/Sources/Permissions/EventKitPermissionRequester.swift @@ -0,0 +1,51 @@ +import EventKit + +protocol EventKitPermissionStore: AnyObject { + func requestWriteOnlyAccessToEvents( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void) + func requestFullAccessToEvents( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void) + func requestFullAccessToReminders( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void) +} + +extension EKEventStore: EventKitPermissionStore {} + +/// Keeps one event store alive while Calendar or Reminders permission sheets are active. +/// +/// EventKit recommends retaining a long-lived store. A temporary store can disappear +/// before its asynchronous authorization callback completes, leaving the UI waiting +/// even though iOS persisted the user's choice. +/// `PermissionRequestBridge` starts every store request on the main actor; the unchecked +/// conformance only allows that private store owner to cross the bridge's sendable closure. +final class EventKitPermissionRequester: @unchecked Sendable { + private let store: any EventKitPermissionStore + + init(store: any EventKitPermissionStore = EKEventStore()) { + self.store = store + } + + func requestWriteOnlyAccessToEvents() async -> Bool { + await PermissionRequestBridge.awaitRequest { [self] completion in + self.store.requestWriteOnlyAccessToEvents { granted, _ in + completion(granted) + } + } + } + + func requestFullAccessToEvents() async -> Bool { + await PermissionRequestBridge.awaitRequest { [self] completion in + self.store.requestFullAccessToEvents { granted, _ in + completion(granted) + } + } + } + + func requestFullAccessToReminders() async -> Bool { + await PermissionRequestBridge.awaitRequest { [self] completion in + self.store.requestFullAccessToReminders { granted, _ in + completion(granted) + } + } + } +} diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift index b4af1380f240..9cfd2accf2ae 100644 --- a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift +++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift @@ -34,6 +34,7 @@ struct PrivacyAccessSectionView: View { @State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder) @State private var photosStatus = PhotoLibraryAccess.authorizationStatus() @State private var requestingIdentifiers: Set = [] + @State private var eventKitPermissions = EventKitPermissionRequester() @Environment(\.scenePhase) private var scenePhase @@ -255,30 +256,15 @@ struct PrivacyAccessSectionView: View { } private func requestCalendarWriteOnly() async -> Bool { - await PermissionRequestBridge.awaitRequest { completion in - let store = EKEventStore() - store.requestWriteOnlyAccessToEvents { granted, _ in - completion(granted) - } - } + await self.eventKitPermissions.requestWriteOnlyAccessToEvents() } private func requestCalendarFull() async -> Bool { - await PermissionRequestBridge.awaitRequest { completion in - let store = EKEventStore() - store.requestFullAccessToEvents { granted, _ in - completion(granted) - } - } + await self.eventKitPermissions.requestFullAccessToEvents() } private func requestRemindersFull() async -> Bool { - await PermissionRequestBridge.awaitRequest { completion in - let store = EKEventStore() - store.requestFullAccessToReminders { granted, _ in - completion(granted) - } - } + await self.eventKitPermissions.requestFullAccessToReminders() } private func openSettings() { diff --git a/apps/ios/Tests/EventKitPermissionRequesterTests.swift b/apps/ios/Tests/EventKitPermissionRequesterTests.swift new file mode 100644 index 000000000000..6d36fcf3478f --- /dev/null +++ b/apps/ios/Tests/EventKitPermissionRequesterTests.swift @@ -0,0 +1,66 @@ +import EventKit +@testable import OpenClaw +import Testing + +@Suite(.serialized) struct EventKitPermissionRequesterTests { + @Test func `requester retains its event store`() throws { + var store: TestEventKitPermissionStore? = TestEventKitPermissionStore() + let weakStore = WeakEventKitPermissionStore(store) + + let requester = EventKitPermissionRequester(store: try #require(store)) + store = nil + + withExtendedLifetime(requester) { + #expect(weakStore.value != nil) + } + } + + @Test func `requester routes every EventKit grant through its retained store`() async { + let store = TestEventKitPermissionStore() + let requester = EventKitPermissionRequester(store: store) + + #expect(await requester.requestWriteOnlyAccessToEvents()) + #expect(await requester.requestFullAccessToEvents()) + #expect(await requester.requestFullAccessToReminders()) + #expect(store.requests == [.writeOnlyEvents, .fullEvents, .fullReminders]) + } +} + +private final class WeakEventKitPermissionStore { + weak var value: TestEventKitPermissionStore? + + init(_ value: TestEventKitPermissionStore?) { + self.value = value + } +} + +private final class TestEventKitPermissionStore: EventKitPermissionStore { + enum Request: Equatable { + case writeOnlyEvents + case fullEvents + case fullReminders + } + + private(set) var requests: [Request] = [] + + func requestWriteOnlyAccessToEvents( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void + ) { + requests.append(.writeOnlyEvents) + completion(true, nil) + } + + func requestFullAccessToEvents( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void + ) { + requests.append(.fullEvents) + completion(true, nil) + } + + func requestFullAccessToReminders( + completion: @escaping @Sendable (Bool, (any Error)?) -> Void + ) { + requests.append(.fullReminders) + completion(true, nil) + } +}