585 lines
23 KiB
Swift
585 lines
23 KiB
Swift
import CloudKit
|
|
import Foundation
|
|
import UIKit
|
|
|
|
class ICloudSyncProvider: SyncProvider {
|
|
// MARK: - Properties
|
|
|
|
var type: SyncProviderType { .iCloud }
|
|
|
|
var isConfigured: Bool {
|
|
return true
|
|
}
|
|
|
|
var isConnected: Bool {
|
|
get { userDefaults.bool(forKey: Keys.isConnected) }
|
|
set { userDefaults.set(newValue, forKey: Keys.isConnected) }
|
|
}
|
|
|
|
var lastSyncTime: Date? {
|
|
return userDefaults.object(forKey: Keys.lastSyncTime) as? Date
|
|
}
|
|
|
|
private let container = CKContainer.default()
|
|
private lazy var database = container.privateCloudDatabase
|
|
private let zoneID = CKRecordZone.ID(zoneName: "AscentlyZone", ownerName: CKCurrentUserDefaultName)
|
|
|
|
private let userDefaults = UserDefaults.standard
|
|
private let logTag = "ICloudSync"
|
|
|
|
private enum Keys {
|
|
static let serverChangeToken = "Ascently.ICloud.ServerChangeToken"
|
|
static let lastSyncTime = "Ascently.ICloud.LastSyncTime"
|
|
static let zoneCreated = "Ascently.ICloud.ZoneCreated"
|
|
static let isConnected = "Ascently.ICloud.IsConnected"
|
|
}
|
|
|
|
// MARK: - Init
|
|
|
|
init() {
|
|
Task {
|
|
try? await checkAccountStatus()
|
|
}
|
|
}
|
|
|
|
// MARK: - SyncProvider Protocol
|
|
|
|
func testConnection() async throws {
|
|
try await checkAccountStatus()
|
|
}
|
|
|
|
func disconnect() {
|
|
isConnected = false
|
|
}
|
|
|
|
func sync(dataManager: ClimbingDataManager) async throws {
|
|
if !isConnected {
|
|
try await checkAccountStatus()
|
|
if !isConnected {
|
|
throw SyncError.notConnected
|
|
}
|
|
}
|
|
|
|
AppLogger.info("Starting iCloud sync", tag: logTag)
|
|
|
|
do {
|
|
try await createZoneIfNeeded()
|
|
|
|
do {
|
|
try await pullChanges(dataManager: dataManager)
|
|
} catch {
|
|
let errorString = String(describing: error)
|
|
if errorString.contains("recordName") && errorString.contains("queryable") {
|
|
AppLogger.warning("Schema initialization detected. Skipping pull to attempt self-healing via push.", tag: logTag)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
try await pushChanges(dataManager: dataManager)
|
|
|
|
userDefaults.set(Date(), forKey: Keys.lastSyncTime)
|
|
|
|
AppLogger.info("iCloud sync completed successfully", tag: logTag)
|
|
} catch {
|
|
AppLogger.error("iCloud sync failed: \(error.localizedDescription)", tag: logTag)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func checkAccountStatus() async throws {
|
|
let status: CKAccountStatus
|
|
do {
|
|
status = try await container.accountStatus()
|
|
} catch {
|
|
AppLogger.error("Failed to get iCloud account status: \(error). This often indicates a mismatch between the App Bundle ID and the iCloud Container configuration in the Provisioning Profile.", tag: logTag)
|
|
throw error
|
|
}
|
|
|
|
AppLogger.info("iCloud account status: \(status.rawValue)", tag: logTag)
|
|
|
|
switch status {
|
|
case .available:
|
|
isConnected = true
|
|
case .noAccount:
|
|
isConnected = false
|
|
throw SyncError.providerError("No iCloud account found. Please sign in to iCloud settings.")
|
|
case .restricted:
|
|
isConnected = false
|
|
throw SyncError.providerError("iCloud access is restricted.")
|
|
case .couldNotDetermine:
|
|
isConnected = false
|
|
throw SyncError.providerError("Could not determine iCloud account status.")
|
|
case .temporarilyUnavailable:
|
|
isConnected = false
|
|
throw SyncError.providerError("iCloud is temporarily unavailable.")
|
|
@unknown default:
|
|
isConnected = false
|
|
throw SyncError.providerError("Unknown iCloud error.")
|
|
}
|
|
}
|
|
|
|
private func createZoneIfNeeded() async throws {
|
|
if userDefaults.bool(forKey: Keys.zoneCreated) {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let zone = CKRecordZone(zoneID: zoneID)
|
|
try await database.save(zone)
|
|
userDefaults.set(true, forKey: Keys.zoneCreated)
|
|
AppLogger.info("Created custom record zone: \(zoneID.zoneName)", tag: logTag)
|
|
} catch {
|
|
// If zone already exists, that's fine
|
|
if let ckError = error as? CKError {
|
|
if ckError.code == .serverRecordChanged {
|
|
userDefaults.set(true, forKey: Keys.zoneCreated)
|
|
return
|
|
}
|
|
|
|
if ckError.code == .permissionFailure {
|
|
AppLogger.error("CloudKit Permission Failure. This usually indicates 'Invalid Bundle ID'. Please Clean Build Folder and ensure Provisioning Profile matches entitlements.", tag: logTag)
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Pull (Fetch from CloudKit)
|
|
|
|
private func pullChanges(dataManager: ClimbingDataManager) async throws {
|
|
var previousToken: CKServerChangeToken? = nil
|
|
if let tokenData = userDefaults.data(forKey: Keys.serverChangeToken) {
|
|
previousToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
|
|
}
|
|
|
|
var changedRecords: [CKRecord] = []
|
|
var deletedRecordIDs: [CKRecord.ID] = []
|
|
|
|
let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
|
config.previousServerChangeToken = previousToken
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: config])
|
|
|
|
operation.recordWasChangedBlock = { recordID, result in
|
|
switch result {
|
|
case .success(let record):
|
|
changedRecords.append(record)
|
|
case .failure(let error):
|
|
AppLogger.error("Failed to fetch record \(recordID): \(error)", tag: self.logTag)
|
|
}
|
|
}
|
|
|
|
operation.recordWithIDWasDeletedBlock = { recordID, _ in
|
|
deletedRecordIDs.append(recordID)
|
|
}
|
|
|
|
operation.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
|
|
if let token = token {
|
|
self.saveChangeToken(token)
|
|
}
|
|
}
|
|
|
|
operation.fetchRecordZoneChangesResultBlock = { result in
|
|
switch result {
|
|
case .success:
|
|
Task {
|
|
await self.applyChanges(changedRecords: changedRecords, deletedRecordIDs: deletedRecordIDs, dataManager: dataManager)
|
|
continuation.resume()
|
|
}
|
|
case .failure(let error):
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
|
|
database.add(operation)
|
|
}
|
|
}
|
|
|
|
private func saveChangeToken(_ token: CKServerChangeToken) {
|
|
if let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) {
|
|
userDefaults.set(data, forKey: Keys.serverChangeToken)
|
|
}
|
|
}
|
|
|
|
private func applyChanges(changedRecords: [CKRecord], deletedRecordIDs: [CKRecord.ID], dataManager: ClimbingDataManager) async {
|
|
guard !changedRecords.isEmpty || !deletedRecordIDs.isEmpty else { return }
|
|
|
|
AppLogger.info("Applying CloudKit changes: \(changedRecords.count) updates, \(deletedRecordIDs.count) deletions", tag: logTag)
|
|
|
|
await MainActor.run {
|
|
for recordID in deletedRecordIDs {
|
|
let uuidString = recordID.recordName
|
|
if let uuid = UUID(uuidString: uuidString) {
|
|
if let gym = dataManager.gym(withId: uuid) { dataManager.deleteGym(gym) }
|
|
else if let problem = dataManager.problem(withId: uuid) { dataManager.deleteProblem(problem) }
|
|
else if let session = dataManager.session(withId: uuid) { dataManager.deleteSession(session) }
|
|
else if let attempt = dataManager.attempts.first(where: { $0.id == uuid }) { dataManager.deleteAttempt(attempt) }
|
|
}
|
|
}
|
|
|
|
for record in changedRecords {
|
|
guard let uuid = UUID(uuidString: record.recordID.recordName) else { continue }
|
|
|
|
switch record.recordType {
|
|
case "Gym":
|
|
if let gym = mapRecordToGym(record, id: uuid) {
|
|
if let existing = dataManager.gym(withId: uuid) {
|
|
if gym.updatedAt >= existing.updatedAt {
|
|
dataManager.updateGym(gym)
|
|
}
|
|
} else {
|
|
dataManager.addGym(gym)
|
|
}
|
|
}
|
|
case "Problem":
|
|
if let problem = mapRecordToProblem(record, id: uuid) {
|
|
if let existing = dataManager.problem(withId: uuid) {
|
|
if problem.updatedAt >= existing.updatedAt {
|
|
dataManager.updateProblem(problem)
|
|
}
|
|
} else {
|
|
dataManager.addProblem(problem)
|
|
}
|
|
}
|
|
case "ClimbSession":
|
|
if let session = mapRecordToSession(record, id: uuid) {
|
|
if let existingIndex = dataManager.sessions.firstIndex(where: { $0.id == uuid }) {
|
|
let existing = dataManager.sessions[existingIndex]
|
|
if session.updatedAt >= existing.updatedAt {
|
|
dataManager.sessions[existingIndex] = session
|
|
dataManager.saveSessions()
|
|
}
|
|
} else {
|
|
dataManager.sessions.append(session)
|
|
dataManager.saveSessions()
|
|
}
|
|
}
|
|
case "Attempt":
|
|
if let attempt = mapRecordToAttempt(record, id: uuid) {
|
|
if let existingIndex = dataManager.attempts.firstIndex(where: { $0.id == uuid }) {
|
|
let existing = dataManager.attempts[existingIndex]
|
|
if attempt.updatedAt >= existing.updatedAt {
|
|
dataManager.attempts[existingIndex] = attempt
|
|
dataManager.saveAttempts()
|
|
}
|
|
} else {
|
|
dataManager.attempts.append(attempt)
|
|
dataManager.saveAttempts()
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Push (Upload to CloudKit)
|
|
|
|
private func pushChanges(dataManager: ClimbingDataManager) async throws {
|
|
let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date ?? Date.distantPast
|
|
let modifiedGyms = dataManager.gyms.filter { $0.updatedAt > lastSync }
|
|
let modifiedProblems = dataManager.problems.filter { $0.updatedAt > lastSync }
|
|
let modifiedSessions = dataManager.sessions.filter { $0.updatedAt > lastSync }
|
|
let modifiedAttempts = dataManager.attempts.filter { $0.createdAt > lastSync }
|
|
|
|
let deletedItems = dataManager.getDeletedItems().filter { item in
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
if let date = dateFormatter.date(from: item.deletedAt) {
|
|
return date > lastSync
|
|
}
|
|
return false
|
|
}
|
|
|
|
if modifiedGyms.isEmpty && modifiedProblems.isEmpty && modifiedSessions.isEmpty && modifiedAttempts.isEmpty && deletedItems.isEmpty {
|
|
AppLogger.info("No local changes to push", tag: logTag)
|
|
return
|
|
}
|
|
|
|
var recordsToSave: [CKRecord] = []
|
|
var recordIDsToDelete: [CKRecord.ID] = []
|
|
|
|
for item in deletedItems {
|
|
recordIDsToDelete.append(CKRecord.ID(recordName: item.id, zoneID: zoneID))
|
|
}
|
|
|
|
for gym in modifiedGyms {
|
|
recordsToSave.append(createRecord(from: gym))
|
|
}
|
|
for problem in modifiedProblems {
|
|
recordsToSave.append(createRecord(from: problem))
|
|
}
|
|
for session in modifiedSessions {
|
|
recordsToSave.append(createRecord(from: session))
|
|
}
|
|
for attempt in modifiedAttempts {
|
|
recordsToSave.append(createRecord(from: attempt))
|
|
}
|
|
|
|
guard !recordsToSave.isEmpty || !recordIDsToDelete.isEmpty else { return }
|
|
|
|
AppLogger.info("Pushing to iCloud: \(recordsToSave.count) saves, \(recordIDsToDelete.count) deletions", tag: logTag)
|
|
|
|
let operation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
|
|
operation.savePolicy = .changedKeys // Merges changes if possible, simpler than handling tags manually
|
|
operation.isAtomic = true // Transactional
|
|
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
operation.modifyRecordsResultBlock = { result in
|
|
switch result {
|
|
case .success:
|
|
continuation.resume()
|
|
case .failure(let error):
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
database.add(operation)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mappers (To CKRecord)
|
|
|
|
private func createRecord(from gym: Gym) -> CKRecord {
|
|
let recordID = CKRecord.ID(recordName: gym.id.uuidString, zoneID: zoneID)
|
|
let record = CKRecord(recordType: "Gym", recordID: recordID)
|
|
|
|
record["name"] = gym.name
|
|
record["location"] = gym.location
|
|
record["notes"] = gym.notes
|
|
record["createdAt"] = gym.createdAt
|
|
record["updatedAt"] = gym.updatedAt
|
|
|
|
record["supportedClimbTypes"] = encode(gym.supportedClimbTypes)
|
|
record["difficultySystems"] = encode(gym.difficultySystems)
|
|
record["customDifficultyGrades"] = encode(gym.customDifficultyGrades)
|
|
|
|
return record
|
|
}
|
|
|
|
private func createRecord(from problem: Problem) -> CKRecord {
|
|
let recordID = CKRecord.ID(recordName: problem.id.uuidString, zoneID: zoneID)
|
|
let record = CKRecord(recordType: "Problem", recordID: recordID)
|
|
|
|
record["gymId"] = problem.gymId.uuidString
|
|
record["name"] = problem.name
|
|
record["description"] = problem.description
|
|
record["climbType"] = problem.climbType.rawValue
|
|
record["difficulty"] = encode(problem.difficulty)
|
|
record["tags"] = encode(problem.tags)
|
|
record["location"] = problem.location
|
|
record["isActive"] = problem.isActive ? 1 : 0
|
|
record["dateSet"] = problem.dateSet
|
|
record["notes"] = problem.notes
|
|
record["createdAt"] = problem.createdAt
|
|
record["updatedAt"] = problem.updatedAt
|
|
|
|
var assets: [CKAsset] = []
|
|
for path in problem.imagePaths {
|
|
let fullPath = ImageManager.shared.getFullPath(from: path)
|
|
let fileURL = URL(fileURLWithPath: fullPath)
|
|
if FileManager.default.fileExists(atPath: fullPath) {
|
|
assets.append(CKAsset(fileURL: fileURL))
|
|
}
|
|
}
|
|
if !assets.isEmpty {
|
|
record["images"] = assets
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
private func createRecord(from session: ClimbSession) -> CKRecord {
|
|
let recordID = CKRecord.ID(recordName: session.id.uuidString, zoneID: zoneID)
|
|
let record = CKRecord(recordType: "ClimbSession", recordID: recordID)
|
|
|
|
record["gymId"] = session.gymId.uuidString
|
|
record["date"] = session.date
|
|
record["startTime"] = session.startTime
|
|
record["endTime"] = session.endTime
|
|
record["duration"] = session.duration
|
|
record["status"] = session.status.rawValue
|
|
record["notes"] = session.notes
|
|
record["createdAt"] = session.createdAt
|
|
record["updatedAt"] = session.updatedAt
|
|
|
|
return record
|
|
}
|
|
|
|
private func createRecord(from attempt: Attempt) -> CKRecord {
|
|
let recordID = CKRecord.ID(recordName: attempt.id.uuidString, zoneID: zoneID)
|
|
let record = CKRecord(recordType: "Attempt", recordID: recordID)
|
|
|
|
record["sessionId"] = attempt.sessionId.uuidString
|
|
record["problemId"] = attempt.problemId.uuidString
|
|
record["result"] = attempt.result.rawValue
|
|
record["highestHold"] = attempt.highestHold
|
|
record["notes"] = attempt.notes
|
|
record["duration"] = attempt.duration
|
|
record["restTime"] = attempt.restTime
|
|
record["timestamp"] = attempt.timestamp
|
|
record["createdAt"] = attempt.createdAt
|
|
record["updatedAt"] = attempt.updatedAt
|
|
|
|
return record
|
|
}
|
|
|
|
// MARK: - Mappers (From CKRecord)
|
|
|
|
private func mapRecordToGym(_ record: CKRecord, id: UUID) -> Gym? {
|
|
guard let name = record["name"] as? String,
|
|
let supportedClimbTypesData = record["supportedClimbTypes"] as? String,
|
|
let difficultySystemsData = record["difficultySystems"] as? String,
|
|
let createdAt = record["createdAt"] as? Date,
|
|
let updatedAt = record["updatedAt"] as? Date
|
|
else { return nil }
|
|
|
|
let location = record["location"] as? String
|
|
let notes = record["notes"] as? String
|
|
let customGradesData = record["customDifficultyGrades"] as? String ?? "[]"
|
|
|
|
let supportedClimbTypes: [ClimbType] = decode(supportedClimbTypesData) ?? []
|
|
let difficultySystems: [DifficultySystem] = decode(difficultySystemsData) ?? []
|
|
let customDifficultyGrades: [String] = decode(customGradesData) ?? []
|
|
|
|
return Gym.fromImport(
|
|
id: id,
|
|
name: name,
|
|
location: location,
|
|
supportedClimbTypes: supportedClimbTypes,
|
|
difficultySystems: difficultySystems,
|
|
customDifficultyGrades: customDifficultyGrades,
|
|
notes: notes,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
|
|
private func mapRecordToProblem(_ record: CKRecord, id: UUID) -> Problem? {
|
|
guard let gymIdString = record["gymId"] as? String,
|
|
let gymId = UUID(uuidString: gymIdString),
|
|
let climbTypeRaw = record["climbType"] as? String,
|
|
let climbType = ClimbType(rawValue: climbTypeRaw),
|
|
let difficultyData = record["difficulty"] as? String,
|
|
let difficulty: DifficultyGrade = decode(difficultyData),
|
|
let createdAt = record["createdAt"] as? Date,
|
|
let updatedAt = record["updatedAt"] as? Date
|
|
else { return nil }
|
|
|
|
let name = record["name"] as? String
|
|
let description = record["description"] as? String
|
|
let tagsData = record["tags"] as? String ?? "[]"
|
|
let tags: [String] = decode(tagsData) ?? []
|
|
let location = record["location"] as? String
|
|
let isActive = (record["isActive"] as? Int ?? 1) == 1
|
|
let dateSet = record["dateSet"] as? Date
|
|
let notes = record["notes"] as? String
|
|
|
|
var imagePaths: [String] = []
|
|
if let assets = record["images"] as? [CKAsset] {
|
|
for (index, asset) in assets.enumerated() {
|
|
guard let fileURL = asset.fileURL else { continue }
|
|
let filename = ImageNamingUtils.generateImageFilename(problemId: id.uuidString, imageIndex: index)
|
|
|
|
if let data = try? Data(contentsOf: fileURL) {
|
|
_ = try? ImageManager.shared.saveImportedImage(data, filename: filename)
|
|
imagePaths.append(filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
return Problem.fromImport(
|
|
id: id,
|
|
gymId: gymId,
|
|
name: name,
|
|
description: description,
|
|
climbType: climbType,
|
|
difficulty: difficulty,
|
|
tags: tags,
|
|
location: location,
|
|
imagePaths: imagePaths,
|
|
isActive: isActive,
|
|
dateSet: dateSet,
|
|
notes: notes,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
|
|
private func mapRecordToSession(_ record: CKRecord, id: UUID) -> ClimbSession? {
|
|
guard let gymIdString = record["gymId"] as? String,
|
|
let gymId = UUID(uuidString: gymIdString),
|
|
let date = record["date"] as? Date,
|
|
let statusRaw = record["status"] as? String,
|
|
let status = SessionStatus(rawValue: statusRaw),
|
|
let createdAt = record["createdAt"] as? Date,
|
|
let updatedAt = record["updatedAt"] as? Date
|
|
else { return nil }
|
|
|
|
let startTime = record["startTime"] as? Date
|
|
let endTime = record["endTime"] as? Date
|
|
let duration = record["duration"] as? Int
|
|
let notes = record["notes"] as? String
|
|
|
|
return ClimbSession.fromImport(
|
|
id: id,
|
|
gymId: gymId,
|
|
date: date,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
duration: duration,
|
|
status: status,
|
|
notes: notes,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
|
|
private func mapRecordToAttempt(_ record: CKRecord, id: UUID) -> Attempt? {
|
|
guard let sessionIdString = record["sessionId"] as? String,
|
|
let sessionId = UUID(uuidString: sessionIdString),
|
|
let problemIdString = record["problemId"] as? String,
|
|
let problemId = UUID(uuidString: problemIdString),
|
|
let resultRaw = record["result"] as? String,
|
|
let result = AttemptResult(rawValue: resultRaw),
|
|
let timestamp = record["timestamp"] as? Date,
|
|
let createdAt = record["createdAt"] as? Date,
|
|
let updatedAt = record["updatedAt"] as? Date
|
|
else { return nil }
|
|
|
|
let highestHold = record["highestHold"] as? String
|
|
let notes = record["notes"] as? String
|
|
let duration = record["duration"] as? Int
|
|
let restTime = record["restTime"] as? Int
|
|
|
|
return Attempt.fromImport(
|
|
id: id,
|
|
sessionId: sessionId,
|
|
problemId: problemId,
|
|
result: result,
|
|
highestHold: highestHold,
|
|
notes: notes,
|
|
duration: duration,
|
|
restTime: restTime,
|
|
timestamp: timestamp,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
|
|
// MARK: - Helper Coding
|
|
|
|
private func encode<T: Encodable>(_ value: T) -> String {
|
|
guard let data = try? JSONEncoder().encode(value) else { return "" }
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|
}
|
|
|
|
private func decode<T: Decodable>(_ json: String) -> T? {
|
|
guard let data = json.data(using: .utf8) else { return nil }
|
|
return try? JSONDecoder().decode(T.self, from: data)
|
|
}
|
|
}
|