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)
    }
}