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

159 lines
5.1 KiB
Swift
Raw Permalink Normal View History

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