CalendarBridge.swift
284 lignes · 11736 octets
import EventKit import Foundation /// Encodes/decodes the remote `serverId` of an event inside its description/notes. /// /// Same stable line-prefix format as Android (`CalendarContract.Events.DESCRIPTION`); /// on iOS the payload lives in `EKEvent.notes`. The user-visible description /// (`CalendarEventSnapshot.descriptionBody`) immediately follows the prefix line. enum CalendarServerIdCodec { private static let PREFIX = "card2vcf:serverId=" static func encode(serverId: String?, descriptionBody: String) -> String { guard let serverId else { return descriptionBody } return "\(PREFIX)\(serverId)\n\(descriptionBody)" } /// Returns `(serverId, descriptionBody)`; `serverId` is `nil` when the prefix is absent. static func decode(_ description: String?) -> (String?, String) { guard let description else { return (nil, "") } guard description.hasPrefix(PREFIX) else { return (nil, description) } let rest = String(description.dropFirst(PREFIX.count)) guard let newlineIndex = rest.firstIndex(of: "\n") else { return (rest, "") } return ( String(rest[rest.startIndex..<newlineIndex]), String(rest[rest.index(after: newlineIndex)...]) ) } } /// Title prefix distinguishing unavailabilities (read-only mirror) from reservations/RDV. enum CalendarEventTitles { private static let INDISPO_PREFIX = "[Indispo] " static func indispoTitle(_ title: String) -> String { "\(INDISPO_PREFIX)\(title)" } static func isIndispoTitle(_ title: String) -> Bool { title.hasPrefix(INDISPO_PREFIX) } static func stripIndispoPrefix(_ title: String) -> String { title.hasPrefix(INDISPO_PREFIX) ? String(title.dropFirst(INDISPO_PREFIX.count)) : title } } /// Calendar-bridge contract used by `SyncEngine`/`AgendaSyncCoordinator` /// (Kotlin `CalendarBridgeApi`); `EventKitCalendarBridge` implements it, fake in tests. protocol CalendarBridge { /// True while the device still exposes the local calendar source. iOS hides it /// when iCloud Calendar is on; the Settings flow then asks the user before /// falling back to the account source (no Android equivalent: the local /// calendar account always exists there). func hasLocalSource() -> Bool @discardableResult func ensureLocalCalendar(displayName: String) throws -> Int64 /// Deletes the local calendar and (cascading, like Android's provider) all its events. func deleteCalendar(calendarId: Int64) throws func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] @discardableResult func upsertEvent(calendarId: Int64, snap: CalendarEventSnapshot) throws -> Int64 func deleteEvent(eventId: Int64) throws } enum CalendarBridgeError: Error { case accessDenied case noLocalSource case calendarCreationFailed(String) case eventCreationFailed(String) } /// EventKit bridge over **local** calendars (mirror of Android's /// `ACCOUNT_TYPE_LOCAL` calendars: works without any cloud account). /// /// Deviations from the Android `ContentResolver` bridge, by necessity: /// - EventKit identifies calendars/events with `String` identifiers; the Room /// entities store `Int64` ids, so a persistent `Int64 ⇄ identifier` map /// (UserDefaults) assigns stable numeric ids. /// - EventKit event queries require a bounded date window (max 4 years per /// predicate); `listEvents` uses [now − 1 year, now + 3 years]. final class EventKitCalendarBridge: CalendarBridge { private let store: EKEventStore private let ids: EventKitIdMap init(store: EKEventStore = EKEventStore(), defaults: UserDefaults = .standard) { self.store = store self.ids = EventKitIdMap(defaults: defaults) } /// Requests calendar access: iOS 17 full-access API when available, /// legacy `requestAccess(to:)` otherwise. func requestFullAccess() async throws -> Bool { if #available(iOS 17.0, *) { return try await store.requestFullAccessToEvents() } return try await withCheckedThrowingContinuation { continuation in store.requestAccess(to: .event) { granted, error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: granted) } } } } func hasLocalSource() -> Bool { store.sources.contains { $0.sourceType == .local } } /// Creates the local calendar `displayName` if missing, and returns its id. /// When iCloud hides the local source, the calendar lands in the default /// account source — the Settings flow obtains user consent first. @discardableResult func ensureLocalCalendar(displayName: String) throws -> Int64 { if let existing = findCalendar(displayName: displayName) { return ids.id(for: existing.calendarIdentifier) } guard let source = localSource() else { throw CalendarBridgeError.noLocalSource } let calendar = EKCalendar(for: .event, eventStore: store) calendar.title = displayName calendar.source = source do { try store.saveCalendar(calendar, commit: true) } catch { throw CalendarBridgeError.calendarCreationFailed(displayName) } return ids.id(for: calendar.calendarIdentifier) } /// Removes the local calendar `calendarId` from the device; EventKit deletes its /// events in cascade. An unknown id is a no-op (Android `delete` returning 0). func deleteCalendar(calendarId: Int64) throws { guard let calendar = calendar(for: calendarId) else { return } try store.removeCalendar(calendar, commit: true) ids.remove(id: calendarId) } func listEvents(calendarId: Int64) throws -> [CalendarEventSnapshot] { guard let calendar = calendar(for: calendarId) else { return [] } let start = Date(timeIntervalSinceNow: -365 * 24 * 3600) let end = Date(timeIntervalSinceNow: 3 * 365 * 24 * 3600) let predicate = store.predicateForEvents(withStart: start, end: end, calendars: [calendar]) return store.events(matching: predicate).compactMap { event in guard let identifier = event.eventIdentifier else { return nil } let (serverId, body) = CalendarServerIdCodec.decode(event.notes) return CalendarEventSnapshot( title: event.title ?? "", debutMs: Int64((event.startDate?.timeIntervalSince1970 ?? 0) * 1000), finMs: Int64((event.endDate?.timeIntervalSince1970 ?? 0) * 1000), eventId: ids.id(for: identifier), serverId: serverId, descriptionBody: body ) } } /// Creates the event (when `snap.eventId == nil`) or updates it, and returns its id. @discardableResult func upsertEvent(calendarId: Int64, snap: CalendarEventSnapshot) throws -> Int64 { if let existingEventId = snap.eventId { // Mirror Android: update-by-id is a no-op when the row vanished; keep the id. if let identifier = ids.identifier(for: existingEventId), let event = store.event(withIdentifier: identifier) { apply(snap, to: event, calendarId: calendarId) try store.save(event, span: .thisEvent, commit: true) } return existingEventId } let event = EKEvent(eventStore: store) apply(snap, to: event, calendarId: calendarId) do { try store.save(event, span: .thisEvent, commit: true) } catch { throw CalendarBridgeError.eventCreationFailed(snap.title) } guard let identifier = event.eventIdentifier else { throw CalendarBridgeError.eventCreationFailed(snap.title) } return ids.id(for: identifier) } func deleteEvent(eventId: Int64) throws { guard let identifier = ids.identifier(for: eventId), let event = store.event(withIdentifier: identifier) else { return } try store.remove(event, span: .thisEvent, commit: true) ids.remove(id: eventId) } // MARK: - Helpers private func apply(_ snap: CalendarEventSnapshot, to event: EKEvent, calendarId: Int64) { if let calendar = calendar(for: calendarId) { event.calendar = calendar } event.title = snap.title event.notes = CalendarServerIdCodec.encode(serverId: snap.serverId, descriptionBody: snap.descriptionBody) event.startDate = Date(timeIntervalSince1970: Double(snap.debutMs) / 1000.0) event.endDate = Date(timeIntervalSince1970: Double(snap.finMs) / 1000.0) event.timeZone = TimeZone.current } /// Finds an existing calendar by title among writable calendars, local source /// first. A consented activation may have created it in the account source /// (iCloud hiding the local one): matching it too avoids duplicates when a /// binding is removed then re-enabled. private func findCalendar(displayName: String) -> EKCalendar? { let candidates = store.calendars(for: .event).filter { $0.title == displayName && $0.allowsContentModifications } return candidates.first { $0.source?.sourceType == .local } ?? candidates.first } private func calendar(for id: Int64) -> EKCalendar? { guard let identifier = ids.identifier(for: id) else { return nil } return store.calendar(withIdentifier: identifier) } private func localSource() -> EKSource? { store.sources.first { $0.sourceType == .local } ?? store.defaultCalendarForNewEvents?.source } } /// Persistent `Int64 ⇄ EventKit identifier` map (calendars and events share the /// same namespace; EventKit identifiers are unique strings). final class EventKitIdMap { private static let mapKey = "fr.ebii.card2vcf.sync.eventkit_ids" private static let nextIdKey = "fr.ebii.card2vcf.sync.eventkit_next_id" private let defaults: UserDefaults private var idToIdentifier: [String: String] private var identifierToId: [String: Int64] private var nextId: Int64 init(defaults: UserDefaults = .standard) { self.defaults = defaults let stored = defaults.dictionary(forKey: Self.mapKey) as? [String: String] ?? [:] self.idToIdentifier = stored var reverse: [String: Int64] = [:] for (idString, identifier) in stored { if let id = Int64(idString) { reverse[identifier] = id } } self.identifierToId = reverse let storedNext = defaults.object(forKey: Self.nextIdKey) as? NSNumber self.nextId = storedNext?.int64Value ?? 1 } /// Stable id for the identifier, assigning a new one if needed. func id(for identifier: String) -> Int64 { if let existing = identifierToId[identifier] { return existing } let id = nextId nextId += 1 identifierToId[identifier] = id idToIdentifier[String(id)] = identifier persist() return id } func identifier(for id: Int64) -> String? { idToIdentifier[String(id)] } func remove(id: Int64) { guard let identifier = idToIdentifier.removeValue(forKey: String(id)) else { return } identifierToId.removeValue(forKey: identifier) persist() } private func persist() { defaults.set(idToIdentifier, forKey: Self.mapKey) defaults.set(NSNumber(value: nextId), forKey: Self.nextIdKey) } }
GitRust