Files
Ascently/ios/Ascently/Services/Sync/ICloudSyncProvider.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)
}
}