mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ios): finish Calendar and Reminders grants without relaunch (#116504)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -170,10 +170,13 @@ enum DevicePermissionStatusMap {
|
||||
final class DevicePermissionsModel {
|
||||
private(set) var grants: [DevicePermissionKind: DevicePermissionGrant] = [:]
|
||||
private(set) var requesting: Set<DevicePermissionKind> = []
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
@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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user