import Contacts import Darwin import Foundation import GRDB import LilithLogging import MacSyncShared private let log = AppLogger.logger(for: "ICalls.Reader") public struct CallRecord: Sendable { public let uniqueId: String public let address: String? public let normalizedAddress: String? public let contactName: String? public let direction: String // "incoming" | "outgoing" public let callType: String // "telephony" | "facetime_video" | "facetime_audio" | "unknown" public let answered: Bool public let durationSeconds: Double public let startedAt: String // ISO8601 public let serviceProvider: String? public init( uniqueId: String, address: String?, normalizedAddress: String?, contactName: String?, direction: String, callType: String, answered: Bool, durationSeconds: Double, startedAt: String, serviceProvider: String? ) { self.uniqueId = uniqueId self.address = address self.normalizedAddress = normalizedAddress self.contactName = contactName self.direction = direction self.callType = callType self.answered = answered self.durationSeconds = durationSeconds self.startedAt = startedAt self.serviceProvider = serviceProvider } } /// Reads call history from the system CallHistory.storedata (CoreData SQLite). /// /// Supports both historical and current filesystem locations. /// Uses the same readonly GRDB approach as IMessageSync for chat.db. /// Timestamps are Apple reference date (seconds since 2001-01-01). /// Incremental via caller-provided `since` Date (compares against ZDATE). /// Enriches with CNContact display names when ZNAME is absent (best-effort). public final class CallHistoryReader: @unchecked Sendable { public static let shared = CallHistoryReader() private let contactStore = CNContactStore() private var contactCache: [String: String] = [:] // normalized phone or identifier -> display name private let possiblePaths: [String] private init() { let home = FileManager.default.homeDirectoryForCurrentUser possiblePaths = [ home.appendingPathComponent("Library/Application Support/CallHistoryDB/CallHistory.storedata").path, home.appendingPathComponent("Library/CallHistoryDB/CallHistory.storedata").path, ] } public var isAccessible: Bool { for p in possiblePaths { if FileManager.default.fileExists(atPath: p) && Darwin.access(p, R_OK) == 0 { return true } } return false } private func loadContacts() { do { let status = CNContactStore.authorizationStatus(for: .contacts) if status == .notDetermined { // Fire and forget; we are best-effort for names only. Task { _ = try? await contactStore.requestAccess(for: .contacts) } return } guard status == .authorized else { return } let keysToFetch: [CNKeyDescriptor] = [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, ] let request = CNContactFetchRequest(keysToFetch: keysToFetch) try contactStore.enumerateContacts(with: request) { contact, _ in let fullName = [contact.givenName, contact.familyName] .filter { !$0.isEmpty } .joined(separator: " ") guard !fullName.isEmpty else { return } let phones = contact.phoneNumbers.map { PhoneUtils.normalize($0.value.stringValue) } let emails = contact.emailAddresses.map { ($0.value as String).lowercased() } for phone in phones { self.contactCache[phone] = fullName } for email in emails { self.contactCache[email] = fullName } self.contactCache[contact.identifier] = fullName } log.info("Loaded \(self.contactCache.count) contact entries for call enrichment") } catch { log.warning("Failed to load contacts for call name enrichment: \(error)") } } private func lookupContactName(for address: String) -> String? { let norm = PhoneUtils.normalize(address) if let name = contactCache[norm] { return name } if let name = contactCache[address.lowercased()] { return name } return nil } private func resolvedPath() -> String? { for p in possiblePaths where FileManager.default.fileExists(atPath: p) { return p } return nil } /// Fetch calls with ZDATE strictly after `since` (if provided). /// Returns oldest-first so server sees them in order. /// /// Reads from a **snapshot** of the live DB (the `.storedata` plus its /// `-wal`/`-shm` sidecars copied into a temp dir) rather than the live file. /// CallHistory commits recent calls to the WAL and checkpoints lazily, so a /// readonly connection to the live file routinely misses the newest rows — /// exactly the calls triage cares about. Copying the WAL alongside the main /// db and opening the copy read-write replays those frames, and never /// contends with the system writer's lock. public func fetchCalls(since: Date?) -> [CallRecord] { guard let livePath = resolvedPath() else { log.warning("CallHistory DB not found") return [] } if contactCache.isEmpty { loadContacts() } return readCalls(dbPath: livePath, since: since) } /// Snapshot `dbPath`, open the copy, and parse matching rows. Internal so /// tests can drive it against a fixture DB (incl. unflushed WAL rows). func readCalls(dbPath: String, since: Date?) -> [CallRecord] { guard let snapshot = makeSnapshot(of: dbPath) else { log.warning("CallHistory snapshot failed") return [] } defer { try? FileManager.default.removeItem(at: snapshot.dir) } do { // Read-write (not readonly) on the private copy so SQLite rebuilds // the shared-memory index and replays the copied WAL frames. let queue = try DatabaseQueue(path: snapshot.dbPath) return try queue.read { db in var sql = """ SELECT Z_PK, ZUNIQUE_ID, ZADDRESS, ZNAME, ZORIGINATED, ZANSWERED, ZDURATION, ZDATE, ZCALLTYPE, ZSERVICE_PROVIDER FROM ZCALLRECORD """ var arguments: [DatabaseValueConvertible] = [] if let since = since { sql += " WHERE ZDATE > ?" arguments.append(since.timeIntervalSinceReferenceDate) } sql += " ORDER BY ZDATE ASC" let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments)) return rows.map { Self.parse(row: $0, contactLookup: self.lookupContactName) } } } catch { log.warning("readCalls query failed: \(error)") return [] } } /// Copy the CoreData store and its `-wal`/`-shm` sidecars into a fresh temp /// dir. Returns the snapshot dir (caller deletes it) and the copied db path. private func makeSnapshot(of dbPath: String) -> (dir: URL, dbPath: String)? { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("icalls-snap-\(UUID().uuidString)", isDirectory: true) do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) let base = (dbPath as NSString).lastPathComponent for suffix in ["", "-wal", "-shm"] { let src = dbPath + suffix guard fm.fileExists(atPath: src) else { continue } try fm.copyItem(atPath: src, toPath: dir.appendingPathComponent(base + suffix).path) } return (dir, dir.appendingPathComponent(base).path) } catch { log.warning("makeSnapshot failed: \(error)") try? fm.removeItem(at: dir) return nil } } /// Map a `ZCALLRECORD` row to a `CallRecord`. Pure (modulo the contact /// lookup closure) so the parsing/classification matrix is unit-testable. static func parse(row: Row, contactLookup: (String) -> String?) -> CallRecord { // Use GRDB's typed subscript (not `as?`): SQLite integers come back as // Int64, so `as? Int` silently fails and every call would read as an // unanswered incoming. GRDB converts the stored type to the annotation. let zpk: Int64 = row["Z_PK"] ?? 0 let zunique: String = row["ZUNIQUE_ID"] ?? "" let uniqueId = zunique.isEmpty ? "zpk:\(zpk)" : zunique let rawAddress: String? = row["ZADDRESS"] let rawName: String? = row["ZNAME"] let nameFromDb = (rawName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? rawName : nil let resolvedName = nameFromDb ?? rawAddress.flatMap { contactLookup($0) } let originated: Int = row["ZORIGINATED"] ?? 0 let direction = originated == 1 ? "outgoing" : "incoming" let answeredRaw: Int = row["ZANSWERED"] ?? 0 let answered = answeredRaw != 0 let duration: Double = row["ZDURATION"] ?? 0.0 let zdate: Double = row["ZDATE"] ?? 0.0 let startedAt = ISO8601DateFormatter().string(from: Date(timeIntervalSinceReferenceDate: zdate)) let ctypeRaw: Int = row["ZCALLTYPE"] ?? 0 let svc: String? = row["ZSERVICE_PROVIDER"] let normAddr = rawAddress.map { PhoneUtils.normalize($0) } return CallRecord( uniqueId: uniqueId, address: rawAddress, normalizedAddress: normAddr, contactName: resolvedName, direction: direction, callType: classifyCallType(serviceProvider: svc, callTypeRaw: ctypeRaw), answered: answered, durationSeconds: duration, startedAt: startedAt, serviceProvider: svc ) } /// Classify a call from its service provider + `ZCALLTYPE`. /// FaceTime audio is `ZCALLTYPE == 16`; FaceTime video is everything else /// under the FaceTime provider. Cellular maps to `telephony`. static func classifyCallType(serviceProvider: String?, callTypeRaw: Int) -> String { let svcLower = serviceProvider?.lowercased() ?? "" if svcLower.contains("facetime") { return callTypeRaw == 16 ? "facetime_audio" : "facetime_video" } if callTypeRaw == 1 || svcLower.contains("telephony") { return "telephony" } return "unknown" } }