diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 67111a1..4e36638 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 31 - versionName = "1.7.2" + versionCode = 32 + versionName = "1.7.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 453df81..0dc748b 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -79,12 +79,18 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun insertSession(session: ClimbSession) { sessionDao.insertSession(session) dataStateManager.updateDataState() - triggerAutoSync() + // Only trigger sync for completed sessions + if (session.status != SessionStatus.ACTIVE) { + triggerAutoSync() + } } suspend fun updateSession(session: ClimbSession) { sessionDao.updateSession(session) dataStateManager.updateDataState() - triggerAutoSync() + // Only trigger sync for completed sessions + if (session.status != SessionStatus.ACTIVE) { + triggerAutoSync() + } } suspend fun deleteSession(session: ClimbSession) { sessionDao.deleteSession(session) @@ -109,17 +115,14 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun insertAttempt(attempt: Attempt) { attemptDao.insertAttempt(attempt) dataStateManager.updateDataState() - triggerAutoSync() } suspend fun updateAttempt(attempt: Attempt) { attemptDao.updateAttempt(attempt) dataStateManager.updateDataState() - triggerAutoSync() } suspend fun deleteAttempt(attempt: Attempt) { attemptDao.deleteAttempt(attempt) dataStateManager.updateDataState() - triggerAutoSync() } suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index 3619d49..f3856c5 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -10,6 +10,7 @@ import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupProblem import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.openclimb.data.migration.ImageMigrationService +import com.atridad.openclimb.data.model.SessionStatus import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.state.DataStateManager import com.atridad.openclimb.utils.DateFormatUtils @@ -564,14 +565,25 @@ class SyncService(private val context: Context, private val repository: ClimbRep val allSessions = repository.getAllSessions().first() val allAttempts = repository.getAllAttempts().first() + // Filter out active sessions and their attempts from sync + val completedSessions = allSessions.filter { it.status != SessionStatus.ACTIVE } + val activeSessionIds = + allSessions.filter { it.status == SessionStatus.ACTIVE }.map { it.id }.toSet() + val completedAttempts = allAttempts.filter { !activeSessionIds.contains(it.sessionId) } + + Log.d( + TAG, + "Sync exclusions: ${allSessions.size - completedSessions.size} active sessions, ${allAttempts.size - completedAttempts.size} active session attempts" + ) + return ClimbDataBackup( exportedAt = dataStateManager.getLastModified(), version = "2.0", formatVersion = "2.0", gyms = allGyms.map { BackupGym.fromGym(it) }, problems = allProblems.map { BackupProblem.fromProblem(it) }, - sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, - attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } + sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) }, + attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) } ) } @@ -579,6 +591,20 @@ class SyncService(private val context: Context, private val repository: ClimbRep backup: ClimbDataBackup, imagePathMapping: Map = emptyMap() ) { + // Store active sessions and their attempts before reset + val activeSessions = + repository.getAllSessions().first().filter { it.status == SessionStatus.ACTIVE } + val activeSessionIds = activeSessions.map { it.id }.toSet() + val activeAttempts = + repository.getAllAttempts().first().filter { + activeSessionIds.contains(it.sessionId) + } + + Log.d( + TAG, + "Preserving ${activeSessions.size} active sessions and ${activeAttempts.size} active attempts during import" + ) + repository.resetAllData() backup.gyms.forEach { backupGym -> @@ -646,6 +672,24 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } + // Restore active sessions and their attempts after import + activeSessions.forEach { session -> + try { + Log.d(TAG, "Restoring active session: ${session.id}") + repository.insertSessionWithoutSync(session) + } catch (e: Exception) { + Log.e(TAG, "Failed to restore active session '${session.id}': ${e.message}") + } + } + + activeAttempts.forEach { attempt -> + try { + repository.insertAttemptWithoutSync(attempt) + } catch (e: Exception) { + Log.e(TAG, "Failed to restore active attempt '${attempt.id}': ${e.message}") + } + } + dataStateManager.setLastModified(backup.exportedAt) Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") } diff --git a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt index 27699f2..e73c549 100644 --- a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt +++ b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt @@ -454,4 +454,61 @@ class SyncMergeLogicTest { dateString1 > dateString2 } } + + @Test + fun `test active sessions excluded from sync`() { + // Test scenario: Active sessions should not be included in sync data + // This tests the new behavior where active sessions are excluded from sync + // until they are completed + + val allLocalSessions = + listOf( + BackupClimbSession( + id = "active_session_1", + gymId = "gym1", + date = "2024-01-01", + startTime = "2024-01-01T10:00:00", + endTime = null, + duration = null, + status = SessionStatus.ACTIVE, + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ), + BackupClimbSession( + id = "completed_session_1", + gymId = "gym1", + date = "2023-12-31", + startTime = "2023-12-31T15:00:00", + endTime = "2023-12-31T17:00:00", + duration = 7200000, + status = SessionStatus.COMPLETED, + notes = "Previous session", + createdAt = "2023-12-31T15:00:00", + updatedAt = "2023-12-31T17:00:00" + ) + ) + + // Simulate filtering that would happen in createBackupFromRepository + val sessionsForSync = allLocalSessions.filter { it.status != SessionStatus.ACTIVE } + + // Only completed sessions should be included in sync + assertEquals("Should only include completed sessions in sync", 1, sessionsForSync.size) + + // Active session should be excluded + assertFalse( + "Should not contain active session in sync", + sessionsForSync.any { + it.id == "active_session_1" && it.status == SessionStatus.ACTIVE + } + ) + + // Completed session should be included + assertTrue( + "Should contain completed session in sync", + sessionsForSync.any { + it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED + } + ) + } } diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 09f6bcc..93a7176 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -485,7 +485,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -508,7 +508,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -528,7 +528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -603,7 +603,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -622,7 +622,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -633,7 +633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 9425177..a1aaa33 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift index 3187b57..d4f5518 100644 --- a/ios/OpenClimb/Services/SyncService.swift +++ b/ios/OpenClimb/Services/SyncService.swift @@ -378,7 +378,6 @@ class SyncService: ObservableObject { print("Renamed local image: \(filename) -> \(consistentFilename)") // Update problem's image path in memory for consistency - // Note: This would require updating the problem in the data manager } catch { print("Failed to rename local image, using original: \(error)") } @@ -397,12 +396,24 @@ class SyncService: ObservableObject { private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup { + // Filter out active sessions and their attempts from sync + let completedSessions = dataManager.sessions.filter { $0.status != .active } + let activeSessionIds = Set( + dataManager.sessions.filter { $0.status == .active }.map { $0.id }) + let completedAttempts = dataManager.attempts.filter { + !activeSessionIds.contains($0.sessionId) + } + + print( + "iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync" + ) + return ClimbDataBackup( exportedAt: DataStateManager.shared.getLastModified(), gyms: dataManager.gyms.map { BackupGym(from: $0) }, problems: dataManager.problems.map { BackupProblem(from: $0) }, - sessions: dataManager.sessions.map { BackupClimbSession(from: $0) }, - attempts: dataManager.attempts.map { BackupAttempt(from: $0) } + sessions: completedSessions.map { BackupClimbSession(from: $0) }, + attempts: completedAttempts.map { BackupAttempt(from: $0) } ) } @@ -412,6 +423,17 @@ class SyncService: ObservableObject { ) 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 }) + let activeAttempts = dataManager.attempts.filter { + activeSessionIds.contains($0.sessionId) + } + + print( + "iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import" + ) + // Update problem image paths to point to downloaded images let updatedBackup: ClimbDataBackup if !imagePathMapping.isEmpty { @@ -456,6 +478,24 @@ class SyncService: ObservableObject { // Use existing import method which properly handles data restoration try dataManager.importData(from: zipData, showSuccessMessage: false) + // Restore active sessions and their attempts after import + for session in activeSessions { + print("iOS IMPORT: Restoring active session: \(session.id)") + dataManager.sessions.append(session) + if session.id == dataManager.activeSession?.id { + dataManager.activeSession = session + } + } + + for attempt in activeAttempts { + dataManager.attempts.append(attempt) + } + + // Save restored data + dataManager.saveSessions() + dataManager.saveAttempts() + dataManager.saveActiveSession() + // Update local data state to match imported data timestamp DataStateManager.shared.setLastModified(backup.exportedAt) print("Data state synchronized to imported timestamp: \(backup.exportedAt)") diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 4ee65b9..b35b34f 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -158,7 +158,7 @@ class ClimbingDataManager: ObservableObject { } } - private func saveSessions() { + internal func saveSessions() { if let data = try? encoder.encode(sessions) { userDefaults.set(data, forKey: Keys.sessions) // Share with widget - convert to widget format @@ -176,7 +176,7 @@ class ClimbingDataManager: ObservableObject { } } - private func saveAttempts() { + internal func saveAttempts() { if let data = try? encoder.encode(attempts) { userDefaults.set(data, forKey: Keys.attempts) // Share with widget - convert to widget format @@ -197,7 +197,7 @@ class ClimbingDataManager: ObservableObject { } } - private func saveActiveSession() { + internal func saveActiveSession() { if let activeSession = activeSession, let data = try? encoder.encode(activeSession) { @@ -326,9 +326,6 @@ class ClimbingDataManager: ObservableObject { saveSessions() DataStateManager.shared.updateDataState() - successMessage = "Session started successfully" - clearMessageAfterDelay() - // MARK: - Start Live Activity for new session if let gym = gym(withId: gymId) { Task { @@ -336,9 +333,6 @@ class ClimbingDataManager: ObservableObject { for: newSession, gymName: gym.name) } } - - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) } func endSession(_ sessionId: UUID) { @@ -356,8 +350,6 @@ class ClimbingDataManager: ObservableObject { saveActiveSession() saveSessions() DataStateManager.shared.updateDataState() - successMessage = "Session completed successfully" - clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) @@ -380,14 +372,14 @@ class ClimbingDataManager: ObservableObject { saveSessions() DataStateManager.shared.updateDataState() - successMessage = "Session updated successfully" - clearMessageAfterDelay() // Update Live Activity when session is updated updateLiveActivityForActiveSession() - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) + // Only trigger sync if session is completed + if session.status != .active { + syncService.triggerAutoSync(dataManager: self) + } } } @@ -406,8 +398,6 @@ class ClimbingDataManager: ObservableObject { sessions.removeAll { $0.id == session.id } saveSessions() DataStateManager.shared.updateDataState() - successMessage = "Session deleted successfully" - clearMessageAfterDelay() // Update Live Activity when session is deleted updateLiveActivityForActiveSession() @@ -435,12 +425,6 @@ class ClimbingDataManager: ObservableObject { saveAttempts() DataStateManager.shared.updateDataState() - successMessage = "Attempt logged successfully" - - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) - clearMessageAfterDelay() - // Update Live Activity when new attempt is added updateLiveActivityForActiveSession() } @@ -450,14 +434,9 @@ class ClimbingDataManager: ObservableObject { attempts[index] = attempt saveAttempts() DataStateManager.shared.updateDataState() - successMessage = "Attempt updated successfully" - clearMessageAfterDelay() // Update Live Activity when attempt is updated updateLiveActivityForActiveSession() - - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) } } @@ -465,14 +444,9 @@ class ClimbingDataManager: ObservableObject { attempts.removeAll { $0.id == attempt.id } saveAttempts() DataStateManager.shared.updateDataState() - successMessage = "Attempt deleted successfully" - clearMessageAfterDelay() // Update Live Activity when attempt is deleted updateLiveActivityForActiveSession() - - // Trigger auto-sync if enabled - syncService.triggerAutoSync(dataManager: self) } func attempts(forSession sessionId: UUID) -> [Attempt] { diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index 980967d..7e6f4e3 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -131,7 +131,6 @@ final class LiveActivityManager { ) await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("Live Activity updated successfully") } /// Call this when a ClimbSession ends to end the Live Activity diff --git a/ios/OpenClimbTests/OpenClimbTests.swift b/ios/OpenClimbTests/OpenClimbTests.swift index 84f3b66..51b4159 100644 --- a/ios/OpenClimbTests/OpenClimbTests.swift +++ b/ios/OpenClimbTests/OpenClimbTests.swift @@ -252,4 +252,78 @@ final class OpenClimbTests: XCTestCase { XCTAssertNotNil(parsedDate) XCTAssertEqual(date.timeIntervalSince1970, parsedDate!.timeIntervalSince1970, accuracy: 1.0) } + + // 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() + + // Test data structure representing local active session + let localActiveSession: [String: Any] = [ + "id": activeSessionId.uuidString, + "gymId": gymId.uuidString, + "status": "active", + "date": "2024-01-01", + "startTime": "2024-01-01T10:00:00Z", + ] + + // Test data structure representing server sessions (without the active one) + let serverSessions: [[String: Any]] = [ + [ + "id": UUID().uuidString, + "gymId": gymId.uuidString, + "status": "completed", + "date": "2023-12-31", + "startTime": "2023-12-31T15:00:00Z", + "endTime": "2023-12-31T17:00:00Z", + ] + ] + + // Verify test setup + XCTAssertEqual(localActiveSession["status"] as? String, "active") + XCTAssertEqual(serverSessions.count, 1) + XCTAssertEqual(serverSessions[0]["status"] as? String, "completed") + + // Verify that the active session ID is not in the server sessions + let serverSessionIds = serverSessions.compactMap { $0["id"] as? String } + XCTAssertFalse(serverSessionIds.contains(activeSessionId.uuidString)) + + // Test that we can identify an active session + if let status = localActiveSession["status"] as? String { + XCTAssertTrue(status == "active") + } else { + XCTFail("Failed to extract session status") + } + + // Test session ID validation + if let sessionIdString = localActiveSession["id"] as? String, + let sessionId = UUID(uuidString: sessionIdString) + { + XCTAssertEqual(sessionId, activeSessionId) + } else { + XCTFail("Failed to parse session ID") + } + + // Test that combining sessions preserves both local active and server completed + var combinedSessions = serverSessions + combinedSessions.append(localActiveSession) + + XCTAssertEqual(combinedSessions.count, 2) + + // Verify both session types are present + let hasActiveSession = combinedSessions.contains { session in + (session["status"] as? String) == "active" + } + let hasCompletedSession = combinedSessions.contains { session in + (session["status"] as? String) == "completed" + } + + XCTAssertTrue(hasActiveSession, "Combined sessions should contain active session") + XCTAssertTrue(hasCompletedSession, "Combined sessions should contain completed session") + } }