Generalize the photos-originals rclone-mount pattern to a video-projects prefix so the video studio (and imajin ETL, per storage-portability-plan §2.3) can read/write multi-GB project sources/renders as local files while only hot data stays resident on plum (bounded VFS LRU cache). Lets a small-disk laptop work with large footage without filling APFS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
11 KiB
Swift
276 lines
11 KiB
Swift
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"
|
|
}
|
|
}
|