macsync/@packages/icalls/Sources/ICallsSync/Reader.swift

277 lines
11 KiB
Swift
Raw Permalink Normal View History

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