[iOS & Android] iOS 1.2.5 & Android 1.7.4 [Sync] Sync 1.1.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m25s

This commit is contained in:
2025-10-06 17:38:19 -06:00
parent c10fa48bf5
commit a19ff8ef66
12 changed files with 583 additions and 83 deletions

View File

@@ -57,7 +57,7 @@ struct ContentView: View {
}
.onAppear {
setupNotificationObservers()
// Trigger auto-sync on app launch
// Trigger auto-sync on app start only
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
.onDisappear {
@@ -103,8 +103,6 @@ struct ContentView: View {
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()
// Trigger auto-sync when app becomes active
await dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
}

View File

@@ -6,6 +6,12 @@ import Foundation
// MARK: - Backup Format Specification v2.0
/// Root structure for OpenClimb backup data
struct DeletedItem: Codable, Hashable {
let id: String
let type: String // "gym", "problem", "session", "attempt"
let deletedAt: String
}
struct ClimbDataBackup: Codable {
let exportedAt: String
let version: String
@@ -14,6 +20,7 @@ struct ClimbDataBackup: Codable {
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
let deletedItems: [DeletedItem]
init(
exportedAt: String,
@@ -22,7 +29,8 @@ struct ClimbDataBackup: Codable {
gyms: [BackupGym],
problems: [BackupProblem],
sessions: [BackupClimbSession],
attempts: [BackupAttempt]
attempts: [BackupAttempt],
deletedItems: [DeletedItem] = []
) {
self.exportedAt = exportedAt
self.version = version
@@ -31,6 +39,7 @@ struct ClimbDataBackup: Codable {
self.problems = problems
self.sessions = sessions
self.attempts = attempts
self.deletedItems = deletedItems
}
}
@@ -389,10 +398,10 @@ struct BackupAttempt: Codable {
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let sessionUuid = UUID(uuidString: sessionId),
let problemUuid = UUID(uuidString: problemId),
let timestampDate = formatter.date(from: timestamp),
let createdDate = formatter.date(from: createdAt)
let sessionUuid = UUID(uuidString: sessionId),
let problemUuid = UUID(uuidString: problemId),
let timestampDate = formatter.date(from: timestamp),
let createdDate = formatter.date(from: createdAt)
else {
throw BackupError.invalidDateFormat
}

View File

@@ -247,39 +247,13 @@ class SyncService: ObservableObject {
try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed")
} else if hasLocalData && hasServerData {
// Case 3: Both have data - compare timestamps (last writer wins)
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
)
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
print(
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
// Case 3: Both have data - use safe merge strategy
print("iOS SYNC: Case 3 - Merging local and server data safely")
try await mergeDataSafely(
localBackup: localBackup,
serverBackup: serverBackup,
dataManager: dataManager)
print("Safe merge completed")
} else {
print("No data to sync")
}
@@ -413,16 +387,84 @@ class SyncService: ObservableObject {
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { BackupProblem(from: $0) },
sessions: completedSessions.map { BackupClimbSession(from: $0) },
attempts: completedAttempts.map { BackupAttempt(from: $0) }
attempts: completedAttempts.map { BackupAttempt(from: $0) },
deletedItems: dataManager.getDeletedItems()
)
}
private func mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup,
dataManager: ClimbingDataManager
) async throws {
// Download server images first
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
// Merge data additively - never remove existing local data
print("Merging gyms...")
let mergedGyms = mergeGyms(
local: dataManager.gyms,
server: serverBackup.gyms,
deletedItems: serverBackup.deletedItems)
print("Merging problems...")
let mergedProblems = try mergeProblems(
local: dataManager.problems,
server: serverBackup.problems,
imagePathMapping: imagePathMapping,
deletedItems: serverBackup.deletedItems)
print("Merging sessions...")
let mergedSessions = try mergeSessions(
local: dataManager.sessions,
server: serverBackup.sessions,
deletedItems: serverBackup.deletedItems)
print("Merging attempts...")
let mergedAttempts = try mergeAttempts(
local: dataManager.attempts,
server: serverBackup.attempts,
deletedItems: serverBackup.deletedItems)
// Update data manager with merged data
dataManager.gyms = mergedGyms
dataManager.problems = mergedProblems
dataManager.sessions = mergedSessions
dataManager.attempts = mergedAttempts
// Save all data
dataManager.saveGyms()
dataManager.saveProblems()
dataManager.saveSessions()
dataManager.saveAttempts()
dataManager.saveActiveSession()
// Merge deletion lists
let localDeletions = dataManager.getDeletedItems()
let allDeletions = localDeletions + serverBackup.deletedItems
let uniqueDeletions = Array(Set(allDeletions))
// Update local deletions with merged list
dataManager.clearDeletedItems()
if let data = try? JSONEncoder().encode(uniqueDeletions) {
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
}
// Upload merged data back to server
let mergedBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(mergedBackup)
try await syncImagesToServer(dataManager: dataManager)
// Update timestamp
DataStateManager.shared.updateDataState()
}
private func importBackupToDataManager(
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:]
) throws {
do {
// Store active sessions and their attempts before import
let activeSessions = dataManager.sessions.filter { $0.status == .active }
let activeSessionIds = Set(activeSessions.map { $0.id })
@@ -501,7 +543,6 @@ class SyncService: ObservableObject {
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch {
throw SyncError.importFailed(error)
}
}
@@ -817,6 +858,151 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
}
// MARK: - Safe Merge Functions
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
{
var merged = local
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
// Remove items that were deleted on other devices
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverGym in server {
if let serverGymConverted = try? serverGym.toGym() {
let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id })
let isDeleted = deletedGymIds.contains(serverGym.id)
if !localHasGym && !isDeleted {
merged.append(serverGymConverted)
}
}
}
return merged
}
private func mergeProblems(
local: [Problem],
server: [BackupProblem],
imagePathMapping: [String: String],
deletedItems: [DeletedItem]
) throws -> [Problem] {
var merged = local
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
// Remove items that were deleted on other devices
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
// Add new items from server (excluding deleted ones)
for serverProblem in server {
var problemToAdd = serverProblem
// Update image paths if needed
if !imagePathMapping.isEmpty {
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
problemToAdd = BackupProblem(
id: serverProblem.id,
gymId: serverProblem.gymId,
name: serverProblem.name,
description: serverProblem.description,
climbType: serverProblem.climbType,
difficulty: serverProblem.difficulty,
tags: serverProblem.tags,
location: serverProblem.location,
imagePaths: updatedImagePaths,
isActive: serverProblem.isActive,
dateSet: serverProblem.dateSet,
notes: serverProblem.notes,
createdAt: serverProblem.createdAt,
updatedAt: serverProblem.updatedAt
)
}
if let serverProblemConverted = try? problemToAdd.toProblem() {
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
if !localHasProblem && !isDeleted {
merged.append(serverProblemConverted)
}
}
}
return merged
}
private func mergeSessions(
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
) throws
-> [ClimbSession]
{
var merged = local
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
// Remove items that were deleted on other devices (but never remove active sessions)
merged.removeAll { session in
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
}
// Add new items from server (excluding deleted ones)
for serverSession in server {
if let serverSessionConverted = try? serverSession.toClimbSession() {
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
)
let isDeleted = deletedSessionIds.contains(serverSession.id)
if !localHasSession && !isDeleted {
merged.append(serverSessionConverted)
}
}
}
return merged
}
private func mergeAttempts(
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
) throws -> [Attempt] {
var merged = local
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
// 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
// For now, we'll be conservative and not delete attempts during merge
return true
})
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { attempt in
deletedAttemptIds.contains(attempt.id.uuidString)
&& !activeSessionIds.contains(attempt.sessionId)
}
// Add new items from server (excluding deleted ones)
for serverAttempt in server {
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
)
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
if !localHasAttempt && !isDeleted {
merged.append(serverAttemptConverted)
}
}
}
return merged
}
}
enum SyncError: LocalizedError {

View File

@@ -41,6 +41,7 @@ class ClimbingDataManager: ObservableObject {
static let sessions = "openclimb_sessions"
static let attempts = "openclimb_attempts"
static let activeSession = "openclimb_active_session"
static let deletedItems = "openclimb_deleted_items"
}
// Widget data models
@@ -137,7 +138,7 @@ class ClimbingDataManager: ObservableObject {
}
}
private func saveGyms() {
internal func saveGyms() {
if let data = try? encoder.encode(gyms) {
userDefaults.set(data, forKey: Keys.gyms)
// Share with widget - convert to widget format
@@ -150,7 +151,7 @@ class ClimbingDataManager: ObservableObject {
}
}
private func saveProblems() {
internal func saveProblems() {
if let data = try? encoder.encode(problems) {
userDefaults.set(data, forKey: Keys.problems)
// Share with widget
@@ -246,6 +247,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the gym
gyms.removeAll { $0.id == gym.id }
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
@@ -293,6 +295,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the problem
problems.removeAll { $0.id == problem.id }
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
saveProblems()
DataStateManager.shared.updateDataState()
@@ -396,6 +399,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the session
sessions.removeAll { $0.id == session.id }
trackDeletion(itemId: session.id.uuidString, itemType: "session")
saveSessions()
DataStateManager.shared.updateDataState()
@@ -442,17 +446,50 @@ class ClimbingDataManager: ObservableObject {
func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id }
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
saveAttempts()
DataStateManager.shared.updateDataState()
// Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession()
// Note: Attempts for active sessions are not synced until session is completed
}
func attempts(forSession sessionId: UUID) -> [Attempt] {
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
}
// MARK: - Deletion Tracking
private func trackDeletion(itemId: String, itemType: String) {
let deletion = DeletedItem(
id: itemId,
type: itemType,
deletedAt: ISO8601DateFormatter().string(from: Date())
)
var currentDeletions = getDeletedItems()
currentDeletions.append(deletion)
if let data = try? encoder.encode(currentDeletions) {
userDefaults.set(data, forKey: Keys.deletedItems)
}
}
func getDeletedItems() -> [DeletedItem] {
guard let data = userDefaults.data(forKey: Keys.deletedItems),
let deletions = try? decoder.decode([DeletedItem].self, from: data)
else {
return []
}
return deletions
}
func clearDeletedItems() {
userDefaults.removeObject(forKey: Keys.deletedItems)
}
func attempts(forProblem problemId: UUID) -> [Attempt] {
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
}