159 lines
5.1 KiB
Swift
159 lines
5.1 KiB
Swift
|
|
import Foundation
|
||
|
|
import LilithLogging
|
||
|
|
import MacSyncShared
|
||
|
|
|
||
|
|
private let log = AppLogger.logger(for: "ICalls.Sync")
|
||
|
|
|
||
|
|
// MARK: - Stats
|
||
|
|
|
||
|
|
public struct ICallsSyncStats: Equatable, Sendable {
|
||
|
|
public var callCount: Int = 0
|
||
|
|
public var syncedThisSession: Int = 0
|
||
|
|
public init() {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Sync Error
|
||
|
|
|
||
|
|
public enum ICallsSyncError: Equatable, Sendable {
|
||
|
|
case none
|
||
|
|
case fullDiskAccessRequired
|
||
|
|
case databaseNotFound
|
||
|
|
case connectionFailed(String)
|
||
|
|
|
||
|
|
public var message: String {
|
||
|
|
switch self {
|
||
|
|
case .none: return ""
|
||
|
|
case .fullDiskAccessRequired: return "Full Disk Access required (Call History)"
|
||
|
|
case .databaseNotFound: return "Call history database not found"
|
||
|
|
case .connectionFailed(let m): return m
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - SyncManager
|
||
|
|
|
||
|
|
/// Orchestrates call history sync: read from CallHistory.storedata → push batches to server.
|
||
|
|
///
|
||
|
|
/// Read-only. 60s tick to keep calls "live" for inbox triage (matches the
|
||
|
|
/// iMessage read cadence); each cycle snapshots the WAL so recent calls show.
|
||
|
|
/// Watermark via BaseSyncManager lastSync (cycle time). First run backfills history.
|
||
|
|
/// Dedup on server via uniqueId (ZUNIQUE_ID or synthetic zpk:).
|
||
|
|
@MainActor
|
||
|
|
public final class SyncManager: BaseSyncManager<ICallsSyncStats, ICallsSyncError> {
|
||
|
|
public static let shared = SyncManager()
|
||
|
|
|
||
|
|
private let reader = CallHistoryReader.shared
|
||
|
|
private let apiClient = APIClient.shared
|
||
|
|
|
||
|
|
private let batchSize = 200
|
||
|
|
|
||
|
|
private init() {
|
||
|
|
super.init(
|
||
|
|
initialStats: ICallsSyncStats(),
|
||
|
|
noError: .none,
|
||
|
|
persistenceKey: "icalls",
|
||
|
|
timerInterval: 60
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func forceFullResync() {
|
||
|
|
guard !isSyncing else { return }
|
||
|
|
log.info("Forcing full resync")
|
||
|
|
setLastSync(nil)
|
||
|
|
syncNow()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Authorization hooks (Full Disk Access for CallHistory.storedata)
|
||
|
|
|
||
|
|
public override func isAuthorized() async -> Bool { reader.isAccessible }
|
||
|
|
|
||
|
|
public override func requestAuthorization() async -> Bool {
|
||
|
|
// Full Disk Access cannot be requested programmatically from the app.
|
||
|
|
// The user must grant it in System Settings > Privacy & Security > Full Disk Access.
|
||
|
|
// We re-probe; if still denied the base will call onAuthorizationDenied.
|
||
|
|
return reader.isAccessible
|
||
|
|
}
|
||
|
|
|
||
|
|
public override func onAuthorizationDenied() {
|
||
|
|
syncError = .fullDiskAccessRequired
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Sync cycle
|
||
|
|
|
||
|
|
public override func performSync() async {
|
||
|
|
log.info("performSync starting")
|
||
|
|
currentOperation = "Reading call history…"
|
||
|
|
|
||
|
|
let watermark = lastSync
|
||
|
|
let calls = await Task.detached(priority: .userInitiated) { [reader] in
|
||
|
|
reader.fetchCalls(since: watermark)
|
||
|
|
}.value
|
||
|
|
|
||
|
|
log.info("Fetched \(calls.count) calls from history")
|
||
|
|
|
||
|
|
guard !calls.isEmpty else {
|
||
|
|
currentOperation = "No new calls"
|
||
|
|
await fetchStats()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
currentOperation = "Syncing \(calls.count) calls…"
|
||
|
|
var totalSynced = 0
|
||
|
|
|
||
|
|
let batches = stride(from: 0, to: calls.count, by: batchSize).map {
|
||
|
|
Array(calls[$0..<min($0 + batchSize, calls.count)])
|
||
|
|
}
|
||
|
|
|
||
|
|
for (idx, batch) in batches.enumerated() {
|
||
|
|
currentOperation = "Syncing… (\(idx + 1)/\(batches.count))"
|
||
|
|
|
||
|
|
let payloads = batch.map { c -> SyncCallPayload in
|
||
|
|
SyncCallPayload(
|
||
|
|
uniqueId: c.uniqueId,
|
||
|
|
address: c.address,
|
||
|
|
normalizedAddress: c.normalizedAddress,
|
||
|
|
contactName: c.contactName,
|
||
|
|
direction: c.direction,
|
||
|
|
callType: c.callType,
|
||
|
|
answered: c.answered,
|
||
|
|
durationSeconds: c.durationSeconds,
|
||
|
|
startedAt: c.startedAt,
|
||
|
|
serviceProvider: c.serviceProvider
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
do {
|
||
|
|
let synced = try await apiClient.syncCalls(payloads)
|
||
|
|
totalSynced += synced
|
||
|
|
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
|
||
|
|
if syncError != .none && !SyncConnectionErrorHeuristic.isConnectionError(NSError(domain: "", code: 0)) {
|
||
|
|
syncError = .none
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
log.warning("Batch \(idx + 1) failed: \(error.localizedDescription)")
|
||
|
|
if SyncConnectionErrorHeuristic.isConnectionError(error) {
|
||
|
|
syncError = .connectionFailed(error.localizedDescription)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
stats.syncedThisSession = totalSynced
|
||
|
|
|
||
|
|
await fetchStats()
|
||
|
|
currentOperation = "Sync complete"
|
||
|
|
log.info("performSync complete: synced=\(totalSynced)")
|
||
|
|
}
|
||
|
|
|
||
|
|
private func fetchStats() async {
|
||
|
|
do {
|
||
|
|
let response = try await apiClient.getStats()
|
||
|
|
stats.callCount = response.totalCalls
|
||
|
|
if let serverLastSync = response.lastSyncAt, lastSync == nil {
|
||
|
|
setLastSync(serverLastSync)
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
log.warning("fetchStats failed: \(error.localizedDescription)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|