[All Platforms] 2.1.0 - Sync Optimizations
This commit is contained in:
@@ -465,7 +465,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -487,7 +487,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -513,7 +513,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -535,7 +535,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -602,7 +602,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -613,7 +613,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 26;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -643,7 +643,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
Binary file not shown.
@@ -111,7 +111,6 @@ struct ContentView: View {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
||||
await dataManager.onAppBecomeActive()
|
||||
// Ensure health integration is verified
|
||||
await dataManager.healthKitService.verifyAndRestoreIntegration()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ struct BackupGym: Codable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// Initialize from native iOS Gym model
|
||||
init(from gym: Gym) {
|
||||
self.id = gym.id.uuidString
|
||||
self.name = gym.name
|
||||
@@ -71,7 +70,6 @@ struct BackupGym: Codable {
|
||||
self.updatedAt = formatter.string(from: gym.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -94,7 +92,6 @@ struct BackupGym: Codable {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Gym model
|
||||
func toGym() throws -> Gym {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
@@ -137,7 +134,6 @@ struct BackupProblem: Codable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// Initialize from native iOS Problem model
|
||||
init(from problem: Problem) {
|
||||
self.id = problem.id.uuidString
|
||||
self.gymId = problem.gymId.uuidString
|
||||
@@ -158,7 +154,6 @@ struct BackupProblem: Codable {
|
||||
self.updatedAt = formatter.string(from: problem.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
gymId: String,
|
||||
@@ -191,7 +186,6 @@ struct BackupProblem: Codable {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Problem model
|
||||
func toProblem() throws -> Problem {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
@@ -224,7 +218,6 @@ struct BackupProblem: Codable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a copy with updated image paths for import processing
|
||||
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
|
||||
return BackupProblem(
|
||||
id: self.id,
|
||||
@@ -258,7 +251,6 @@ struct BackupClimbSession: Codable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// Initialize from native iOS ClimbSession model
|
||||
init(from session: ClimbSession) {
|
||||
self.id = session.id.uuidString
|
||||
self.gymId = session.gymId.uuidString
|
||||
@@ -275,7 +267,6 @@ struct BackupClimbSession: Codable {
|
||||
self.updatedAt = formatter.string(from: session.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
gymId: String,
|
||||
@@ -300,7 +291,6 @@ struct BackupClimbSession: Codable {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS ClimbSession model
|
||||
func toClimbSession() throws -> ClimbSession {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
@@ -347,7 +337,6 @@ struct BackupAttempt: Codable {
|
||||
let createdAt: String
|
||||
let updatedAt: String?
|
||||
|
||||
/// Initialize from native iOS Attempt model
|
||||
init(from attempt: Attempt) {
|
||||
self.id = attempt.id.uuidString
|
||||
self.sessionId = attempt.sessionId.uuidString
|
||||
@@ -365,7 +354,6 @@ struct BackupAttempt: Codable {
|
||||
self.updatedAt = formatter.string(from: attempt.updatedAt)
|
||||
}
|
||||
|
||||
/// Initialize with explicit parameters for import
|
||||
init(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
@@ -392,7 +380,6 @@ struct BackupAttempt: Codable {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Convert to native iOS Attempt model
|
||||
func toAttempt() throws -> Attempt {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
26
ios/Ascently/Models/DeltaSyncFormat.swift
Normal file
26
ios/Ascently/Models/DeltaSyncFormat.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// DeltaSyncFormat.swift
|
||||
// Ascently
|
||||
//
|
||||
// Delta sync structures for incremental data synchronization
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DeltaSyncRequest: Codable {
|
||||
let lastSyncTime: String
|
||||
let gyms: [BackupGym]
|
||||
let problems: [BackupProblem]
|
||||
let sessions: [BackupClimbSession]
|
||||
let attempts: [BackupAttempt]
|
||||
let deletedItems: [DeletedItem]
|
||||
}
|
||||
|
||||
struct DeltaSyncResponse: Codable {
|
||||
let serverTime: String
|
||||
let gyms: [BackupGym]
|
||||
let problems: [BackupProblem]
|
||||
let sessions: [BackupClimbSession]
|
||||
let attempts: [BackupAttempt]
|
||||
let deletedItems: [DeletedItem]
|
||||
}
|
||||
@@ -31,7 +31,6 @@ class HealthKitService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore active workout state
|
||||
private func restoreActiveWorkout() {
|
||||
if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date,
|
||||
let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey),
|
||||
@@ -43,7 +42,6 @@ class HealthKitService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist active workout state
|
||||
private func persistActiveWorkout() {
|
||||
if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId {
|
||||
userDefaults.set(startDate, forKey: workoutStartDateKey)
|
||||
@@ -54,7 +52,6 @@ class HealthKitService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify and restore health integration
|
||||
func verifyAndRestoreIntegration() async {
|
||||
guard isEnabled else { return }
|
||||
|
||||
|
||||
@@ -136,6 +136,314 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func performDeltaSync(dataManager: ClimbingDataManager) async throws {
|
||||
guard isConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(serverURL)/sync/delta") else {
|
||||
throw SyncError.invalidURL
|
||||
}
|
||||
|
||||
// Get last sync time, or use epoch if never synced
|
||||
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let lastSyncString = formatter.string(from: lastSync)
|
||||
|
||||
// Collect items modified since last sync
|
||||
let modifiedGyms = dataManager.gyms.filter { gym in
|
||||
gym.updatedAt > lastSync
|
||||
}.map { BackupGym(from: $0) }
|
||||
|
||||
let modifiedProblems = dataManager.problems.filter { problem in
|
||||
problem.updatedAt > lastSync
|
||||
}.map { problem -> BackupProblem in
|
||||
var backupProblem = BackupProblem(from: problem)
|
||||
if !problem.imagePaths.isEmpty {
|
||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
}
|
||||
return BackupProblem(
|
||||
id: backupProblem.id,
|
||||
gymId: backupProblem.gymId,
|
||||
name: backupProblem.name,
|
||||
description: backupProblem.description,
|
||||
climbType: backupProblem.climbType,
|
||||
difficulty: backupProblem.difficulty,
|
||||
tags: backupProblem.tags,
|
||||
location: backupProblem.location,
|
||||
imagePaths: normalizedPaths,
|
||||
isActive: backupProblem.isActive,
|
||||
dateSet: backupProblem.dateSet,
|
||||
notes: backupProblem.notes,
|
||||
createdAt: backupProblem.createdAt,
|
||||
updatedAt: backupProblem.updatedAt
|
||||
)
|
||||
}
|
||||
return backupProblem
|
||||
}
|
||||
|
||||
let modifiedSessions = dataManager.sessions.filter { session in
|
||||
session.status != .active && session.updatedAt > lastSync
|
||||
}.map { BackupClimbSession(from: $0) }
|
||||
|
||||
let activeSessionIds = Set(
|
||||
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
|
||||
let modifiedAttempts = dataManager.attempts.filter { attempt in
|
||||
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
|
||||
}.map { BackupAttempt(from: $0) }
|
||||
|
||||
let modifiedDeletions = dataManager.getDeletedItems().filter { item in
|
||||
if let deletedDate = formatter.date(from: item.deletedAt) {
|
||||
return deletedDate > lastSync
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
print(
|
||||
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
|
||||
)
|
||||
|
||||
// Create delta request
|
||||
let deltaRequest = DeltaSyncRequest(
|
||||
lastSyncTime: lastSyncString,
|
||||
gyms: modifiedGyms,
|
||||
problems: modifiedProblems,
|
||||
sessions: modifiedSessions,
|
||||
attempts: modifiedAttempts,
|
||||
deletedItems: modifiedDeletions
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let jsonData = try encoder.encode(deltaRequest)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = jsonData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw SyncError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
break
|
||||
case 401:
|
||||
throw SyncError.unauthorized
|
||||
default:
|
||||
throw SyncError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
|
||||
|
||||
print(
|
||||
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
|
||||
)
|
||||
|
||||
// Apply server changes to local data
|
||||
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
|
||||
|
||||
// Sync only modified problem images
|
||||
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
|
||||
|
||||
// Update last sync time to server time
|
||||
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
|
||||
lastSyncTime = serverTime
|
||||
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager)
|
||||
async throws
|
||||
{
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
var imagePathMapping: [String: String] = [:]
|
||||
for problem in response.problems {
|
||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||
|
||||
for (index, imagePath) in imagePaths.enumerated() {
|
||||
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
|
||||
do {
|
||||
let imageData = try await downloadImage(filename: serverFilename)
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id, imageIndex: index)
|
||||
let imageManager = ImageManager.shared
|
||||
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
|
||||
imagePathMapping[serverFilename] = consistentFilename
|
||||
} catch SyncError.imageNotFound {
|
||||
print("Image not found on server: \(serverFilename)")
|
||||
continue
|
||||
} catch {
|
||||
print("Failed to download image \(serverFilename): \(error)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge gyms
|
||||
for backupGym in response.gyms {
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
|
||||
{
|
||||
let existing = dataManager.gyms[index]
|
||||
if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.gyms[index] = try backupGym.toGym()
|
||||
}
|
||||
} else {
|
||||
dataManager.gyms.append(try backupGym.toGym())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge problems
|
||||
for backupProblem in response.problems {
|
||||
var problemToMerge = backupProblem
|
||||
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
|
||||
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
|
||||
problemToMerge = BackupProblem(
|
||||
id: backupProblem.id,
|
||||
gymId: backupProblem.gymId,
|
||||
name: backupProblem.name,
|
||||
description: backupProblem.description,
|
||||
climbType: backupProblem.climbType,
|
||||
difficulty: backupProblem.difficulty,
|
||||
tags: backupProblem.tags,
|
||||
location: backupProblem.location,
|
||||
imagePaths: updatedPaths,
|
||||
isActive: backupProblem.isActive,
|
||||
dateSet: backupProblem.dateSet,
|
||||
notes: backupProblem.notes,
|
||||
createdAt: backupProblem.createdAt,
|
||||
updatedAt: backupProblem.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
if let index = dataManager.problems.firstIndex(where: {
|
||||
$0.id.uuidString == problemToMerge.id
|
||||
}) {
|
||||
let existing = dataManager.problems[index]
|
||||
if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.problems[index] = try problemToMerge.toProblem()
|
||||
}
|
||||
} else {
|
||||
dataManager.problems.append(try problemToMerge.toProblem())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge sessions
|
||||
for backupSession in response.sessions {
|
||||
if let index = dataManager.sessions.firstIndex(where: {
|
||||
$0.id.uuidString == backupSession.id
|
||||
}) {
|
||||
let existing = dataManager.sessions[index]
|
||||
if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.sessions[index] = try backupSession.toClimbSession()
|
||||
}
|
||||
} else {
|
||||
dataManager.sessions.append(try backupSession.toClimbSession())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge attempts
|
||||
for backupAttempt in response.attempts {
|
||||
if let index = dataManager.attempts.firstIndex(where: {
|
||||
$0.id.uuidString == backupAttempt.id
|
||||
}) {
|
||||
let existing = dataManager.attempts[index]
|
||||
if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) {
|
||||
dataManager.attempts[index] = try backupAttempt.toAttempt()
|
||||
}
|
||||
} else {
|
||||
dataManager.attempts.append(try backupAttempt.toAttempt())
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions
|
||||
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
||||
|
||||
// Save all changes
|
||||
dataManager.saveGyms()
|
||||
dataManager.saveProblems()
|
||||
dataManager.saveSessions()
|
||||
dataManager.saveAttempts()
|
||||
|
||||
// Update deletion records
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(uniqueDeletions) {
|
||||
UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
|
||||
}
|
||||
|
||||
DataStateManager.shared.updateDataState()
|
||||
}
|
||||
|
||||
private func applyDeletionsToDataManager(
|
||||
deletions: [DeletedItem], dataManager: ClimbingDataManager
|
||||
) {
|
||||
let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) }
|
||||
dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) }
|
||||
dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) }
|
||||
}
|
||||
|
||||
private func syncModifiedImages(
|
||||
modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager
|
||||
) async throws {
|
||||
guard !modifiedProblems.isEmpty else { return }
|
||||
|
||||
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
|
||||
|
||||
for backupProblem in modifiedProblems {
|
||||
guard
|
||||
let problem = dataManager.problems.first(where: {
|
||||
$0.id.uuidString == backupProblem.id
|
||||
})
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
let imageManager = ImageManager.shared
|
||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
|
||||
|
||||
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||
do {
|
||||
if filename != consistentFilename {
|
||||
let newPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
consistentFilename
|
||||
).path
|
||||
try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
|
||||
}
|
||||
|
||||
try await uploadImage(filename: consistentFilename, imageData: imageData)
|
||||
print("Uploaded modified problem image: \(consistentFilename)")
|
||||
} catch {
|
||||
print("Failed to upload image \(consistentFilename): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadImage(filename: String, imageData: Data) async throws {
|
||||
guard isConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
@@ -246,6 +554,17 @@ class SyncService: ObservableObject {
|
||||
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|
||||
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
|
||||
|
||||
// If both client and server have been synced before, use delta sync
|
||||
if hasLocalData && hasServerData && lastSyncTime != nil {
|
||||
print("iOS SYNC: Using delta sync for incremental updates")
|
||||
try await performDeltaSync(dataManager: dataManager)
|
||||
|
||||
// Update last sync time
|
||||
lastSyncTime = Date()
|
||||
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasLocalData && hasServerData {
|
||||
// Case 1: No local data - do full restore from server
|
||||
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
|
||||
@@ -286,7 +605,6 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses ISO8601 timestamp to milliseconds for comparison
|
||||
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let date = formatter.date(from: timestamp) {
|
||||
@@ -1150,7 +1468,6 @@ class SyncService: ObservableObject {
|
||||
// Get active session IDs to protect their attempts
|
||||
let activeSessionIds = Set(
|
||||
local.compactMap { attempt in
|
||||
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
|
||||
return attempt.sessionId
|
||||
}.filter { sessionId in
|
||||
// Check if this session ID belongs to an active session
|
||||
|
||||
@@ -37,46 +37,36 @@ class DataStateManager {
|
||||
print("iOS Data state updated to: \(now)")
|
||||
}
|
||||
|
||||
/// Gets the current data state timestamp. This represents when any data was last modified
|
||||
/// locally.
|
||||
func getLastModified() -> String {
|
||||
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
|
||||
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
|
||||
return storedTimestamp
|
||||
}
|
||||
|
||||
// If no timestamp is stored, return epoch time to indicate very old data
|
||||
// This ensures server data will be considered newer than uninitialized local data
|
||||
let epochTime = "1970-01-01T00:00:00.000Z"
|
||||
print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)")
|
||||
print("No data state timestamp found - returning epoch time: \(epochTime)")
|
||||
return epochTime
|
||||
}
|
||||
|
||||
/// Sets the data state timestamp to a specific value. Used when importing data from server to
|
||||
/// sync the state.
|
||||
func setLastModified(_ timestamp: String) {
|
||||
userDefaults.set(timestamp, forKey: Keys.lastModified)
|
||||
print("Data state set to: \(timestamp)")
|
||||
}
|
||||
|
||||
/// Resets the data state (for testing or complete data wipe).
|
||||
func reset() {
|
||||
userDefaults.removeObject(forKey: Keys.lastModified)
|
||||
userDefaults.removeObject(forKey: Keys.initialized)
|
||||
print("Data state reset")
|
||||
}
|
||||
|
||||
/// Checks if the data state has been initialized.
|
||||
private func isInitialized() -> Bool {
|
||||
return userDefaults.bool(forKey: Keys.initialized)
|
||||
}
|
||||
|
||||
/// Marks the data state as initialized.
|
||||
private func markAsInitialized() {
|
||||
userDefaults.set(true, forKey: Keys.initialized)
|
||||
}
|
||||
|
||||
/// Gets debug information about the current state.
|
||||
func getDebugInfo() -> String {
|
||||
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
|
||||
}
|
||||
|
||||
@@ -690,7 +690,6 @@ class ImageManager {
|
||||
}
|
||||
|
||||
private func cleanupOrphanedFiles() {
|
||||
// This would need access to the data manager to check which files are actually referenced
|
||||
print("Cleanup would require coordination with data manager")
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ class ImageNamingUtils {
|
||||
)
|
||||
}
|
||||
|
||||
/// Generates the canonical filename that should be used for a problem image
|
||||
static func getCanonicalImageFilename(problemId: String, imageIndex: Int) -> String {
|
||||
return generateImageFilename(problemId: problemId, imageIndex: imageIndex)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct ZipUtils {
|
||||
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
|
||||
var currentOffset: UInt32 = 0
|
||||
|
||||
// Add metadata
|
||||
let metadata = createMetadata(
|
||||
exportData: exportData, referencedImagePaths: referencedImagePaths)
|
||||
let metadataData = metadata.data(using: .utf8) ?? Data()
|
||||
@@ -29,6 +30,7 @@ struct ZipUtils {
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
|
||||
// Encode JSON data
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
||||
@@ -46,44 +48,49 @@ struct ZipUtils {
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
|
||||
print("Processing \(referencedImagePaths.count) referenced image paths")
|
||||
// Process images in batches for better performance
|
||||
print("Processing \(referencedImagePaths.count) images for export")
|
||||
var successfulImages = 0
|
||||
let batchSize = 10
|
||||
let sortedPaths = Array(referencedImagePaths).sorted()
|
||||
|
||||
// Pre-allocate capacity for better memory performance
|
||||
zipData.reserveCapacity(zipData.count + (referencedImagePaths.count * 200_000)) // Estimate 200KB per image
|
||||
|
||||
for (index, imagePath) in sortedPaths.enumerated() {
|
||||
if index % batchSize == 0 {
|
||||
print("Processing images \(index)/\(sortedPaths.count)")
|
||||
}
|
||||
|
||||
for imagePath in referencedImagePaths {
|
||||
print("Processing image path: \(imagePath)")
|
||||
let imageURL = URL(fileURLWithPath: imagePath)
|
||||
let imageName = imageURL.lastPathComponent
|
||||
print("Image name: \(imageName)")
|
||||
|
||||
if FileManager.default.fileExists(atPath: imagePath) {
|
||||
print("Image file exists at: \(imagePath)")
|
||||
do {
|
||||
let imageData = try Data(contentsOf: imageURL)
|
||||
print("Image data size: \(imageData.count) bytes")
|
||||
if imageData.count > 0 {
|
||||
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
|
||||
try addFileToZip(
|
||||
filename: imageEntryName,
|
||||
fileData: imageData,
|
||||
zipData: &zipData,
|
||||
fileEntries: &fileEntries,
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
successfulImages += 1
|
||||
print("Successfully added image to ZIP: \(imageEntryName)")
|
||||
} else {
|
||||
print("Image data is empty for: \(imagePath)")
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read image data for \(imagePath): \(error)")
|
||||
guard FileManager.default.fileExists(atPath: imagePath) else {
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let imageData = try Data(contentsOf: imageURL)
|
||||
if imageData.count > 0 {
|
||||
let imageEntryName = "\(IMAGES_DIR_NAME)/\(imageName)"
|
||||
try addFileToZip(
|
||||
filename: imageEntryName,
|
||||
fileData: imageData,
|
||||
zipData: &zipData,
|
||||
fileEntries: &fileEntries,
|
||||
currentOffset: ¤tOffset
|
||||
)
|
||||
successfulImages += 1
|
||||
}
|
||||
} else {
|
||||
print("Image file does not exist at: \(imagePath)")
|
||||
} catch {
|
||||
print("Failed to read image: \(imageName)")
|
||||
}
|
||||
}
|
||||
|
||||
print("Export completed: \(successfulImages)/\(referencedImagePaths.count) images included")
|
||||
print("Export: included \(successfulImages)/\(referencedImagePaths.count) images")
|
||||
|
||||
// Build central directory
|
||||
centralDirectory.reserveCapacity(fileEntries.count * 100) // Estimate 100 bytes per entry
|
||||
for entry in fileEntries {
|
||||
let centralDirEntry = createCentralDirectoryEntry(
|
||||
filename: entry.name,
|
||||
@@ -372,12 +379,12 @@ struct ZipUtils {
|
||||
return data
|
||||
}
|
||||
|
||||
private static func calculateCRC32(data: Data) -> UInt32 {
|
||||
// CRC32 lookup table for faster calculation
|
||||
private static let crc32Table: [UInt32] = {
|
||||
let polynomial: UInt32 = 0xEDB8_8320
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
|
||||
for byte in data {
|
||||
crc ^= UInt32(byte)
|
||||
var table = [UInt32](repeating: 0, count: 256)
|
||||
for i in 0..<256 {
|
||||
var crc = UInt32(i)
|
||||
for _ in 0..<8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ polynomial
|
||||
@@ -385,6 +392,19 @@ struct ZipUtils {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
table[i] = crc
|
||||
}
|
||||
return table
|
||||
}()
|
||||
|
||||
private static func calculateCRC32(data: Data) -> UInt32 {
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
|
||||
data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
|
||||
for byte in bytes {
|
||||
let index = Int((crc ^ UInt32(byte)) & 0xFF)
|
||||
crc = (crc >> 8) ^ crc32Table[index]
|
||||
}
|
||||
}
|
||||
|
||||
return ~crc
|
||||
|
||||
@@ -653,9 +653,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
return gym(withId: mostUsedGymId)
|
||||
}
|
||||
|
||||
/// Clean up orphaned data - removes attempts that reference non-existent sessions
|
||||
/// and removes duplicate attempts. This ensures data integrity and prevents
|
||||
/// orphaned attempts from appearing in widgets
|
||||
private func cleanupOrphanedData() {
|
||||
let validSessionIds = Set(sessions.map { $0.id })
|
||||
let validProblemIds = Set(problems.map { $0.id })
|
||||
@@ -761,8 +758,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate data integrity and return a report
|
||||
/// This can be called manually to check for issues
|
||||
func validateDataIntegrity() -> String {
|
||||
let validSessionIds = Set(sessions.map { $0.id })
|
||||
let validProblemIds = Set(problems.map { $0.id })
|
||||
@@ -801,8 +796,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
return report
|
||||
}
|
||||
|
||||
/// Manually trigger cleanup of orphaned data
|
||||
/// This can be called from settings or debug menu
|
||||
func manualDataCleanup() {
|
||||
cleanupOrphanedData()
|
||||
successMessage = "Data cleanup completed"
|
||||
@@ -830,12 +823,12 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func exportData() -> Data? {
|
||||
func exportData() async -> Data? {
|
||||
do {
|
||||
// Create backup objects on main thread (they access MainActor-isolated properties)
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||
|
||||
// Create export data with normalized image paths
|
||||
let exportData = ClimbDataBackup(
|
||||
exportedAt: dateFormatter.string(from: Date()),
|
||||
version: "2.0",
|
||||
@@ -846,19 +839,30 @@ class ClimbingDataManager: ObservableObject {
|
||||
attempts: attempts.map { BackupAttempt(from: $0) }
|
||||
)
|
||||
|
||||
// Collect actual image paths from disk for the ZIP
|
||||
let referencedImagePaths = collectReferencedImagePaths()
|
||||
print("Starting export with \(referencedImagePaths.count) images")
|
||||
// Get image manager path info on main thread
|
||||
let imagesDirectory = ImageManager.shared.imagesDirectory.path
|
||||
let problemsForImages = problems
|
||||
|
||||
let zipData = try ZipUtils.createExportZip(
|
||||
exportData: exportData,
|
||||
referencedImagePaths: referencedImagePaths
|
||||
)
|
||||
// Move heavy I/O operations to background thread
|
||||
let zipData = try await Task.detached(priority: .userInitiated) {
|
||||
// Collect actual image paths from disk for the ZIP
|
||||
let referencedImagePaths = await Self.collectReferencedImagePathsStatic(
|
||||
problems: problemsForImages,
|
||||
imagesDirectory: imagesDirectory)
|
||||
print("Starting export with \(referencedImagePaths.count) images")
|
||||
|
||||
print("Export completed successfully")
|
||||
successMessage = "Export completed with \(referencedImagePaths.count) images"
|
||||
let zipData = try await ZipUtils.createExportZip(
|
||||
exportData: exportData,
|
||||
referencedImagePaths: referencedImagePaths
|
||||
)
|
||||
|
||||
print("Export completed successfully")
|
||||
return (zipData, referencedImagePaths.count)
|
||||
}.value
|
||||
|
||||
successMessage = "Export completed with \(zipData.1) images"
|
||||
clearMessageAfterDelay()
|
||||
return zipData
|
||||
return zipData.0
|
||||
} catch {
|
||||
let errorMessage = "Export failed: \(error.localizedDescription)"
|
||||
print("ERROR: \(errorMessage)")
|
||||
@@ -955,36 +959,36 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
extension ClimbingDataManager {
|
||||
private func collectReferencedImagePaths() -> Set<String> {
|
||||
let imagesDirectory = ImageManager.shared.imagesDirectory.path
|
||||
return Self.collectReferencedImagePathsStatic(
|
||||
problems: problems,
|
||||
imagesDirectory: imagesDirectory)
|
||||
}
|
||||
|
||||
private static func collectReferencedImagePathsStatic(
|
||||
problems: [Problem], imagesDirectory: String
|
||||
) -> Set<String> {
|
||||
var imagePaths = Set<String>()
|
||||
print("Starting image path collection...")
|
||||
print("Total problems: \(problems.count)")
|
||||
var missingCount = 0
|
||||
|
||||
for problem in problems {
|
||||
if !problem.imagePaths.isEmpty {
|
||||
print(
|
||||
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
|
||||
)
|
||||
for imagePath in problem.imagePaths {
|
||||
print(" - Stored path: \(imagePath)")
|
||||
|
||||
// Extract just the filename (migration should have normalized these)
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let fullPath = ImageManager.shared.getFullPath(from: filename)
|
||||
print(" - Full disk path: \(fullPath)")
|
||||
let fullPath = (imagesDirectory as NSString).appendingPathComponent(filename)
|
||||
|
||||
if FileManager.default.fileExists(atPath: fullPath) {
|
||||
print(" ✓ File exists")
|
||||
imagePaths.insert(fullPath)
|
||||
} else {
|
||||
print(" ✗ WARNING: File not found at \(fullPath)")
|
||||
// Still add it to let ZipUtils handle the logging
|
||||
missingCount += 1
|
||||
imagePaths.insert(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("Collected \(imagePaths.count) total image paths for export")
|
||||
print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)")
|
||||
return imagePaths
|
||||
}
|
||||
|
||||
@@ -1273,7 +1277,9 @@ extension ClimbingDataManager {
|
||||
) { [weak self] notification in
|
||||
if let updateCount = notification.userInfo?["updateCount"] as? Int {
|
||||
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
|
||||
self?.loadProblems()
|
||||
Task { @MainActor in
|
||||
self?.loadProblems()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ struct AddEditProblemView: View {
|
||||
setupInitialGym()
|
||||
}
|
||||
.onChange(of: dataManager.gyms) {
|
||||
// Ensure a gym is selected when gyms are loaded or changed
|
||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
||||
selectedGym = dataManager.gyms.first
|
||||
}
|
||||
|
||||
@@ -180,10 +180,12 @@ struct DataManagementSection: View {
|
||||
private func exportDataAsync() {
|
||||
isExporting = true
|
||||
Task {
|
||||
let data = await MainActor.run { dataManager.exportData() }
|
||||
isExporting = false
|
||||
if let data = data {
|
||||
activeSheet = .export(data)
|
||||
let data = await dataManager.exportData()
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
if let data = data {
|
||||
activeSheet = .export(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,10 +256,6 @@ final class AscentlyTests: XCTestCase {
|
||||
// MARK: - Active Session Preservation Tests
|
||||
|
||||
func testActiveSessionPreservationDuringImport() throws {
|
||||
// Test that active sessions are preserved during import operations
|
||||
// This tests the fix for the bug where active sessions disappear after sync
|
||||
|
||||
// Simulate an active session that exists locally but not in import data
|
||||
let activeSessionId = UUID()
|
||||
let gymId = UUID()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user