diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index be65167..b400b93 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "com.atridad.ascently" minSdk = 31 targetSdk = 36 - versionCode = 50 - versionName = "2.5.0" + versionCode = 51 + versionName = "2.5.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/ascently/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/ascently/data/format/BackupFormat.kt index 988d1cc..83bf34a 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/format/BackupFormat.kt @@ -13,7 +13,6 @@ data class ClimbDataBackup( val problems: List, val sessions: List, val attempts: List, - val deletedItems: List = emptyList(), ) @Serializable @@ -34,6 +33,7 @@ data class BackupGym( @kotlinx.serialization.SerialName("customDifficultyGrades") val customDifficultyGrades: List? = null, val notes: String? = null, + val isDeleted: Boolean = false, val createdAt: String, val updatedAt: String, ) { @@ -47,10 +47,26 @@ data class BackupGym( difficultySystems = gym.difficultySystems, customDifficultyGrades = gym.customDifficultyGrades.ifEmpty { null }, notes = gym.notes, + isDeleted = false, createdAt = gym.createdAt, updatedAt = gym.updatedAt, ) } + + fun createTombstone(id: String, deletedAt: String): BackupGym { + return BackupGym( + id = id, + name = "DELETED", + location = null, + supportedClimbTypes = emptyList(), + difficultySystems = emptyList(), + customDifficultyGrades = null, + notes = null, + isDeleted = true, + createdAt = deletedAt, + updatedAt = deletedAt, + ) + } } fun toGym(): Gym { @@ -83,6 +99,7 @@ data class BackupProblem( val isActive: Boolean = true, val dateSet: String? = null, val notes: String? = null, + val isDeleted: Boolean = false, val createdAt: String, val updatedAt: String, ) { @@ -106,10 +123,31 @@ data class BackupProblem( isActive = problem.isActive, dateSet = problem.dateSet, notes = problem.notes, + isDeleted = false, createdAt = problem.createdAt, updatedAt = problem.updatedAt, ) } + + fun createTombstone(id: String, deletedAt: String): BackupProblem { + return BackupProblem( + id = id, + gymId = "00000000-0000-0000-0000-000000000000", + name = "DELETED", + description = null, + climbType = ClimbType.values().first(), + difficulty = DifficultyGrade(DifficultySystem.values().first(), "0"), + tags = null, + location = null, + imagePaths = null, + isActive = false, + dateSet = null, + notes = null, + isDeleted = true, + createdAt = deletedAt, + updatedAt = deletedAt, + ) + } } fun toProblem(): Problem { @@ -147,6 +185,7 @@ data class BackupClimbSession( val duration: Long? = null, val status: SessionStatus, val notes: String? = null, + val isDeleted: Boolean = false, val createdAt: String, val updatedAt: String, ) { @@ -161,10 +200,27 @@ data class BackupClimbSession( duration = session.duration, status = session.status, notes = session.notes, + isDeleted = false, createdAt = session.createdAt, updatedAt = session.updatedAt, ) } + + fun createTombstone(id: String, deletedAt: String): BackupClimbSession { + return BackupClimbSession( + id = id, + gymId = "00000000-0000-0000-0000-000000000000", + date = deletedAt, + startTime = null, + endTime = null, + duration = null, + status = SessionStatus.values().first(), + notes = null, + isDeleted = true, + createdAt = deletedAt, + updatedAt = deletedAt, + ) + } } fun toClimbSession(): ClimbSession { @@ -195,6 +251,7 @@ data class BackupAttempt( val duration: Long? = null, val restTime: Long? = null, val timestamp: String, + val isDeleted: Boolean = false, val createdAt: String, val updatedAt: String? = null, ) { @@ -210,10 +267,28 @@ data class BackupAttempt( duration = attempt.duration, restTime = attempt.restTime, timestamp = attempt.timestamp, + isDeleted = false, createdAt = attempt.createdAt, updatedAt = attempt.updatedAt, ) } + + fun createTombstone(id: String, deletedAt: String): BackupAttempt { + return BackupAttempt( + id = id, + sessionId = "00000000-0000-0000-0000-000000000000", + problemId = "00000000-0000-0000-0000-000000000000", + result = AttemptResult.values().first(), + highestHold = null, + notes = null, + duration = null, + restTime = null, + timestamp = deletedAt, + isDeleted = true, + createdAt = deletedAt, + updatedAt = deletedAt, + ) + } } fun toAttempt(): Attempt { diff --git a/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt index f80a158..0fc21c1 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/repository/ClimbRepository.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json import java.io.File +import java.time.Instant class ClimbRepository(database: AscentlyDatabase, private val context: Context) { private val gymDao = database.gymDao() @@ -38,6 +39,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) // Gym operations fun getAllGyms(): Flow> = gymDao.getAllGyms() + suspend fun getAllGymsSync(): List = gymDao.getAllGyms().first() suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) suspend fun insertGym(gym: Gym) { gymDao.insertGym(gym) @@ -60,6 +62,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) // Problem operations fun getAllProblems(): Flow> = problemDao.getAllProblems() + suspend fun getAllProblemsSync(): List = problemDao.getAllProblems().first() suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) suspend fun insertProblem(problem: Problem) { @@ -80,6 +83,7 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) // Session operations fun getAllSessions(): Flow> = sessionDao.getAllSessions() + suspend fun getAllSessionsSync(): List = sessionDao.getAllSessions().first() suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) fun getSessionsByGym(gymId: String): Flow> = sessionDao.getSessionsByGym(gymId) @@ -122,6 +126,8 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) // Attempt operations fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() + suspend fun getAllAttemptsSync(): List = attemptDao.getAllAttempts().first() + suspend fun getAttemptById(id: String): Attempt? = attemptDao.getAttemptById(id) fun getAttemptsBySession(sessionId: String): Flow> = attemptDao.getAttemptsBySession(sessionId) @@ -273,10 +279,9 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) } fun trackDeletion(itemId: String, itemType: String) { - val currentDeletions = getDeletedItems().toMutableList() + cleanupOldDeletions() val newDeletion = DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) - currentDeletions.add(newDeletion) val json = json.encodeToString(newDeletion) deletionPreferences.edit { putString("deleted_$itemId", json) } @@ -304,6 +309,27 @@ class ClimbRepository(database: AscentlyDatabase, private val context: Context) deletionPreferences.edit { clear() } } + private fun cleanupOldDeletions() { + val allPrefs = deletionPreferences.all + val cutoff = Instant.now().minusSeconds(90L * 24 * 60 * 60) + + deletionPreferences.edit { + for ((key, value) in allPrefs) { + if (key.startsWith("deleted_") && value is String) { + try { + val deletion = json.decodeFromString(value) + val deletedAt = Instant.parse(deletion.deletedAt) + if (deletedAt.isBefore(cutoff)) { + remove(key) + } + } catch (_: Exception) { + // Ignore + } + } + } + } + } + private fun validateDataIntegrity( gyms: List, problems: List, diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt index e241c7d..ab9435a 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/AscentlySyncProvider.kt @@ -4,8 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.net.ConnectivityManager import android.net.NetworkCapabilities -import androidx.annotation.RequiresPermission -import androidx.core.content.edit +import android.util.Log import com.atridad.ascently.data.format.BackupAttempt import com.atridad.ascently.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupGym @@ -15,41 +14,36 @@ import com.atridad.ascently.data.repository.ClimbRepository import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.utils.AppLogger import com.atridad.ascently.utils.DateFormatUtils -import com.atridad.ascently.utils.ImageNamingUtils -import com.atridad.ascently.utils.ImageUtils -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File import java.io.IOException -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import java.time.Instant import java.util.concurrent.TimeUnit class AscentlySyncProvider( private val context: Context, private val repository: ClimbRepository, + private val dataStateManager: DataStateManager ) : SyncProvider { - override val type: SyncProviderType = SyncProviderType.SERVER - - private val dataStateManager = DataStateManager(context) + override val type = SyncProviderType.SERVER companion object { private const val TAG = "AscentlySyncProvider" } private val sharedPreferences: SharedPreferences = - context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) + context.getSharedPreferences("ascently_prefs", Context.MODE_PRIVATE) private val httpClient = OkHttpClient.Builder() @@ -61,8 +55,8 @@ class AscentlySyncProvider( private val json = Json { prettyPrint = true ignoreUnknownKeys = true - explicitNulls = false - coerceInputValues = true + encodeDefaults = true + isLenient = true } private val _isConnected = MutableStateFlow(false) @@ -71,11 +65,13 @@ class AscentlySyncProvider( private val _isConfigured = MutableStateFlow(false) override val isConfigured: StateFlow = _isConfigured.asStateFlow() - private var isOfflineMode = false + var isOfflineMode: Boolean + get() = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false) + set(value) = sharedPreferences.edit().putBoolean(Keys.OFFLINE_MODE, value).apply() - private object Keys { - const val SERVER_URL = "server_url" - const val AUTH_TOKEN = "auth_token" + object Keys { + const val SERVER_URL = "sync_server_url" + const val AUTH_TOKEN = "sync_auth_token" const val IS_CONNECTED = "is_connected" const val LAST_SYNC_TIME = "last_sync_time" const val OFFLINE_MODE = "offline_mode" @@ -83,661 +79,571 @@ class AscentlySyncProvider( init { loadInitialState() - updateConfiguredState() } private fun loadInitialState() { _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) - isOfflineMode = sharedPreferences.getBoolean(Keys.OFFLINE_MODE, false) + updateConfiguredState() } private fun updateConfiguredState() { - _isConfigured.value = serverUrl.isNotBlank() && authToken.isNotBlank() + val url = serverUrl + val token = authToken + _isConfigured.value = url.isNotBlank() && token.isNotBlank() } var serverUrl: String get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" set(value) { - sharedPreferences.edit { putString(Keys.SERVER_URL, value) } + sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply() updateConfiguredState() - _isConnected.value = false - sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) } } var authToken: String get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" set(value) { - sharedPreferences.edit { putString(Keys.AUTH_TOKEN, value) } + sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply() updateConfiguredState() - _isConnected.value = false - sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, false) } } - @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) private fun isNetworkAvailable(): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = connectivityManager.activeNetwork ?: return false val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + return when { activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true else -> false } } - @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) - override suspend fun sync() { + override suspend fun sync() = withContext(Dispatchers.IO) { if (isOfflineMode) { - AppLogger.d(TAG) { "Sync skipped: Offline mode is enabled." } - return + AppLogger.i(TAG) { "Sync skipped: Offline mode is enabled." } + return@withContext } - if (!isNetworkAvailable()) { - AppLogger.d(TAG) { "Sync skipped: No internet connection." } - throw SyncException.NetworkError("No internet connection.") - } - if (!_isConfigured.value) { + + if (!isConfigured.value) { throw SyncException.NotConfigured } - if (!_isConnected.value) { + + if (!isNetworkAvailable()) { throw SyncException.NotConnected } - val localBackup = createBackupFromRepository() - val serverBackup = downloadData() - - val hasLocalData = - localBackup.gyms.isNotEmpty() || - localBackup.problems.isNotEmpty() || - localBackup.sessions.isNotEmpty() || - localBackup.attempts.isNotEmpty() - - val hasServerData = - serverBackup.gyms.isNotEmpty() || - serverBackup.problems.isNotEmpty() || - serverBackup.sessions.isNotEmpty() || - serverBackup.attempts.isNotEmpty() - val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) - if (hasLocalData && hasServerData && lastSyncTimeStr != null) { - AppLogger.d(TAG) { "Using delta sync for incremental updates" } - performDeltaSync(lastSyncTimeStr) - } else { - when { - !hasLocalData && hasServerData -> { - AppLogger.d(TAG) { "No local data found, performing full restore from server" } - val imagePathMapping = syncImagesFromServer(serverBackup) - importBackupToRepository(serverBackup, imagePathMapping) - AppLogger.d(TAG) { "Full restore completed" } - } - hasLocalData && !hasServerData -> { - AppLogger.d(TAG) { "No server data found, uploading local data to server" } - uploadData(localBackup) - syncImagesForBackup(localBackup) - AppLogger.d(TAG) { "Initial upload completed" } - } - - hasLocalData && hasServerData -> { - AppLogger.d(TAG) { "Both local and server data exist, merging (server wins)" } - mergeDataSafely(serverBackup) - AppLogger.d(TAG) { "Merge completed" } - } - - else -> { - AppLogger.d(TAG) { "No data to sync" } - } + if (lastSyncTimeStr != null) { + AppLogger.i(TAG) { "Last sync time found ($lastSyncTimeStr), attempting delta sync" } + try { + performDeltaSync() + sharedPreferences.edit() + .putString(Keys.LAST_SYNC_TIME, DateFormatUtils.nowISO8601()) + .apply() + return@withContext + } catch (e: Exception) { + AppLogger.e(TAG, e) { "Delta sync failed, falling back to full sync check: ${e.message}" } } } - val now = DateFormatUtils.nowISO8601() - sharedPreferences.edit { putString(Keys.LAST_SYNC_TIME, now) } + val localBackup = createBackupFromRepository() + val serverBackup = try { + downloadData() + } catch (e: Exception) { + AppLogger.e(TAG, e) { "Failed to download data: ${e.message}" } + throw e + } + + val hasLocalData = localBackup.gyms.isNotEmpty() || + localBackup.problems.isNotEmpty() || + localBackup.sessions.isNotEmpty() || + localBackup.attempts.isNotEmpty() + + val hasServerData = serverBackup.gyms.isNotEmpty() || + serverBackup.problems.isNotEmpty() || + serverBackup.sessions.isNotEmpty() || + serverBackup.attempts.isNotEmpty() + + if (!hasLocalData && hasServerData) { + AppLogger.i(TAG) { "Performing full restore from server" } + AppLogger.i(TAG) { "Syncing images from server first..." } + val imagePathMapping = syncImagesFromServer(serverBackup) + + AppLogger.i(TAG) { "Importing data after images..." } + importBackupToRepository(serverBackup, imagePathMapping) + AppLogger.i(TAG) { "Full restore completed" } + } else if (hasLocalData && !hasServerData) { + AppLogger.i(TAG) { "Uploading local data to server" } + uploadData(localBackup) + AppLogger.i(TAG) { "Uploading local images to server..." } + syncImagesToServer() + AppLogger.i(TAG) { "Initial upload completed" } + } else if (hasLocalData && hasServerData) { + AppLogger.i(TAG) { "Merging local and server data" } + mergeDataSafely(localBackup, serverBackup) + AppLogger.i(TAG) { "Safe merge completed" } + } else { + AppLogger.i(TAG) { "No data to sync" } + } + + sharedPreferences.edit() + .putString(Keys.LAST_SYNC_TIME, DateFormatUtils.nowISO8601()) + .apply() } override fun disconnect() { - serverUrl = "" - authToken = "" _isConnected.value = false - sharedPreferences.edit { - remove(Keys.LAST_SYNC_TIME) - putBoolean(Keys.IS_CONNECTED, false) - } - updateConfiguredState() + sharedPreferences.edit() + .remove(Keys.LAST_SYNC_TIME) + .putBoolean(Keys.IS_CONNECTED, false) + .apply() } - private suspend fun performDeltaSync(lastSyncTimeStr: String) { - AppLogger.d(TAG) { "Starting delta sync with lastSyncTime=$lastSyncTimeStr" } + private suspend fun performDeltaSync() { + val lastSyncTimeStr = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) + ?: throw SyncException.General("No last sync time for delta sync") - val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Date(0) + val lastSyncDate = parseISO8601(lastSyncTimeStr) ?: Instant.EPOCH - val allGyms = repository.getAllGyms().first() - val modifiedGyms = - allGyms - .filter { gym -> parseISO8601(gym.updatedAt)?.after(lastSyncDate) == true } - .map { BackupGym.fromGym(it) } + val allGyms = repository.getAllGymsSync() + val modifiedGyms = allGyms.filter { + (parseISO8601(it.updatedAt) ?: Instant.MIN).isAfter(lastSyncDate) + }.map { BackupGym.fromGym(it) }.toMutableList() - val allProblems = repository.getAllProblems().first() - val modifiedProblems = - allProblems - .filter { problem -> - parseISO8601(problem.updatedAt)?.after(lastSyncDate) == true + val allProblems = repository.getAllProblemsSync() + val modifiedProblems = allProblems.filter { + (parseISO8601(it.updatedAt) ?: Instant.MIN).isAfter(lastSyncDate) + }.map { problem -> + val backupProblem = BackupProblem.fromProblem(problem) + if (!problem.imagePaths.isEmpty()) { + val normalizedPaths = problem.imagePaths.mapIndexed { index, _ -> + "${problem.id}_${index}.jpg" } - .map { problem -> - val backupProblem = BackupProblem.fromProblem(problem) - val normalizedImagePaths = - problem.imagePaths.mapIndexed { index, _ -> - ImageNamingUtils.generateImageFilename(problem.id, index) - } - if (normalizedImagePaths.isNotEmpty()) { - backupProblem.copy(imagePaths = normalizedImagePaths) - } else { - backupProblem - } - } - - val allSessions = repository.getAllSessions().first() - val modifiedSessions = - allSessions - .filter { session -> - parseISO8601(session.updatedAt)?.after(lastSyncDate) == true - } - .map { BackupClimbSession.fromClimbSession(it) } - - val allAttempts = repository.getAllAttempts().first() - val modifiedAttempts = - allAttempts - .filter { attempt -> - parseISO8601(attempt.createdAt)?.after(lastSyncDate) == true - } - .map { BackupAttempt.fromAttempt(it) } - - val allDeletions = repository.getDeletedItems() - val modifiedDeletions = - allDeletions.filter { item -> - parseISO8601(item.deletedAt)?.after(lastSyncDate) == true + backupProblem.withUpdatedImagePaths(normalizedPaths) + } else { + backupProblem } + }.toMutableList() - AppLogger.d(TAG) { - "Delta sync sending: gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}, deletions=${modifiedDeletions.size}" + val allSessions = repository.getAllSessionsSync() + val modifiedSessions = allSessions.filter { + (parseISO8601(it.updatedAt) ?: Instant.MIN).isAfter(lastSyncDate) + }.map { BackupClimbSession.fromClimbSession(it) }.toMutableList() + + val allAttempts = repository.getAllAttemptsSync() + val modifiedAttempts = allAttempts.filter { + val updated = it.updatedAt + (parseISO8601(updated) ?: Instant.MIN).isAfter(lastSyncDate) + }.map { BackupAttempt.fromAttempt(it) }.toMutableList() + + val deletedItems = repository.getDeletedItems().filter { + val deletedAt = parseISO8601(it.deletedAt) ?: Instant.MIN + deletedAt.isAfter(lastSyncDate) } - val deltaRequest = - DeltaSyncRequest( - lastSyncTime = lastSyncTimeStr, - gyms = modifiedGyms, - problems = modifiedProblems, - sessions = modifiedSessions, - attempts = modifiedAttempts, - deletedItems = modifiedDeletions, - ) - - val requestBody = - json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest) - .toRequestBody("application/json".toMediaType()) - - val request = - Request.Builder() - .url("$serverUrl/sync/delta") - .header("Authorization", "Bearer $authToken") - .post(requestBody) - .build() - - val deltaResponse = - withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body?.string() - if (!body.isNullOrEmpty()) { - json.decodeFromString(DeltaSyncResponse.serializer(), body) - } else { - throw SyncException.InvalidResponse("Empty response body") - } - } else { - handleHttpError(response.code) - } - } - } catch (e: IOException) { - throw SyncException.NetworkError(e.message ?: "Network error") - } + for (item in deletedItems) { + when (item.type) { + "gym" -> modifiedGyms.add(BackupGym.createTombstone(item.id, item.deletedAt)) + "problem" -> modifiedProblems.add(BackupProblem.createTombstone(item.id, item.deletedAt)) + "session" -> modifiedSessions.add(BackupClimbSession.createTombstone(item.id, item.deletedAt)) + "attempt" -> modifiedAttempts.add(BackupAttempt.createTombstone(item.id, item.deletedAt)) } - - AppLogger.d(TAG) { - "Delta sync received: gyms=${deltaResponse.gyms.size}, " + - "problems=${deltaResponse.problems.size}, sessions=${deltaResponse.sessions.size}, " + - "attempts=${deltaResponse.attempts.size}, deletions=${deltaResponse.deletedItems.size}" } - applyDeltaResponse(deltaResponse) + AppLogger.i(TAG) { "Delta Sync: Sending gyms=${modifiedGyms.size}, problems=${modifiedProblems.size}, sessions=${modifiedSessions.size}, attempts=${modifiedAttempts.size}" } + + val deltaRequest = DeltaSyncRequest( + lastSyncTime = lastSyncTimeStr, + gyms = modifiedGyms, + problems = modifiedProblems, + sessions = modifiedSessions, + attempts = modifiedAttempts + ) + + val requestBody = json.encodeToString(DeltaSyncRequest.serializer(), deltaRequest) + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$serverUrl/sync/delta") + .header("Authorization", "Bearer $authToken") + .post(requestBody) + .build() + + val response = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 401) throw SyncException.Unauthorized + throw SyncException.ServerError(response.code) + } + val body = response.body?.string() ?: throw SyncException.InvalidResponse("Empty response") + json.decodeFromString(body) + } + } + + if (response.requestFullSync) { + throw SyncException.General("Server requested full sync") + } + + AppLogger.i(TAG) { "Delta Sync: Received gyms=${response.gyms.size}, problems=${response.problems.size}, sessions=${response.sessions.size}, attempts=${response.attempts.size}" } + + applyDeltaResponse(response) syncModifiedImages(modifiedProblems) + + repository.clearDeletedItems() + + sharedPreferences.edit() + .putString(Keys.LAST_SYNC_TIME, response.serverTime) + .apply() } - private fun parseISO8601(dateString: String): Date? { + private fun parseISO8601(dateStr: String?): Instant? { + if (dateStr == null) return null return try { - val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) - format.parse(dateString) - } catch (_: Exception) { + Instant.parse(dateStr) + } catch (e: Exception) { null } } private suspend fun applyDeltaResponse(response: DeltaSyncResponse) { - // SyncService handles the "isSyncing" state to prevent recursive sync triggers - // when the repository is modified during a sync operation. + val imagePathMapping = mutableMapOf() + for (problem in response.problems) { + if (problem.isDeleted) continue - try { - // Merge and apply deletions first to prevent resurrection - val allDeletions = repository.getDeletedItems() + response.deletedItems - val uniqueDeletions = allDeletions.distinctBy { "${it.type}:${it.id}" } - - AppLogger.d(TAG) { "Applying ${uniqueDeletions.size} deletion records before merging data" } - applyDeletions(uniqueDeletions) - - // Build deleted item lookup set - val deletedItemSet = uniqueDeletions.map { "${it.type}:${it.id}" }.toSet() - - // Download images for new/modified problems from server - val imagePathMapping = mutableMapOf() - for (problem in response.problems) { - if (deletedItemSet.contains("problem:${problem.id}")) { - continue - } - problem.imagePaths?.forEach { imagePath -> - val serverFilename = imagePath.substringAfterLast('/') - try { - val localImagePath = downloadImage(serverFilename) - if (localImagePath != null) { - imagePathMapping[imagePath] = localImagePath - } - } catch (e: Exception) { - AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" } - } + val paths = problem.imagePaths ?: continue + for ((index, path) in paths.withIndex()) { + val serverFilename = path.substringAfterLast('/') + try { + val imageData = downloadImage(serverFilename) + val localFilename = "${problem.id}_${index}.jpg" + saveImageLocally(localFilename, imageData) + imagePathMapping[serverFilename] = localFilename + } catch (e: Exception) { + AppLogger.e(TAG, e) { "Failed to download image $serverFilename: ${e.message}" } } } - - // Merge gyms - val existingGyms = repository.getAllGyms().first() - for (backupGym in response.gyms) { - if (deletedItemSet.contains("gym:${backupGym.id}")) { - continue - } - val existing = existingGyms.find { it.id == backupGym.id } - if (existing == null || backupGym.updatedAt >= existing.updatedAt) { - val gym = backupGym.toGym() - if (existing != null) { - repository.updateGym(gym) - } else { - repository.insertGym(gym) - } - } - } - - // Merge problems - val existingProblems = repository.getAllProblems().first() - for (backupProblem in response.problems) { - if (deletedItemSet.contains("problem:${backupProblem.id}")) { - continue - } - val updatedImagePaths = - backupProblem.imagePaths?.map { oldPath -> - imagePathMapping[oldPath] ?: oldPath - } - val problemToMerge = backupProblem.copy(imagePaths = updatedImagePaths) - val problem = problemToMerge.toProblem() - - val existing = existingProblems.find { it.id == backupProblem.id } - if (existing == null || backupProblem.updatedAt >= existing.updatedAt) { - if (existing != null) { - repository.updateProblem(problem) - } else { - repository.insertProblem(problem) - } - } - } - - // Merge sessions - val existingSessions = repository.getAllSessions().first() - for (backupSession in response.sessions) { - if (deletedItemSet.contains("session:${backupSession.id}")) { - continue - } - val session = backupSession.toClimbSession() - val existing = existingSessions.find { it.id == backupSession.id } - if (existing == null || backupSession.updatedAt >= existing.updatedAt) { - if (existing != null) { - repository.updateSession(session) - } else { - repository.insertSession(session) - } - } - } - - // Merge attempts - val existingAttempts = repository.getAllAttempts().first() - for (backupAttempt in response.attempts) { - if (deletedItemSet.contains("attempt:${backupAttempt.id}")) { - continue - } - val attempt = backupAttempt.toAttempt() - val existing = existingAttempts.find { it.id == backupAttempt.id } - if (existing == null || backupAttempt.createdAt >= existing.createdAt) { - if (existing != null) { - repository.updateAttempt(attempt) - } else { - repository.insertAttempt(attempt) - } - } - } - - // Apply deletions again for safety - applyDeletions(uniqueDeletions) - - // Update deletion records - repository.clearDeletedItems() - uniqueDeletions.forEach { repository.trackDeletion(it.id, it.type) } - } catch (e: Exception) { - AppLogger.e(TAG, e) { "Error applying delta response" } - throw e } - } - private suspend fun applyDeletions( - deletions: List, - ) { - val existingGyms = repository.getAllGyms().first() - val existingProblems = repository.getAllProblems().first() - val existingSessions = repository.getAllSessions().first() - val existingAttempts = repository.getAllAttempts().first() - - for (item in deletions) { - when (item.type) { - "gym" -> { - existingGyms.find { it.id == item.id }?.let { repository.deleteGym(it) } + for (gym in response.gyms) { + if (gym.isDeleted) { + val serverUpdate = parseISO8601(gym.updatedAt) ?: Instant.MAX + val existing = repository.getGymById(gym.id) + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate) || serverUpdate == localUpdate) { + repository.deleteGym(existing) + } } + continue + } - "problem" -> { - existingProblems.find { it.id == item.id }?.let { repository.deleteProblem(it) } + val existing = repository.getGymById(gym.id) + val serverUpdate = parseISO8601(gym.updatedAt) ?: Instant.MIN + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate)) { + repository.updateGym(gym.toGym()) } + } else { + repository.insertGym(gym.toGym()) + } + } - "session" -> { - existingSessions.find { it.id == item.id }?.let { repository.deleteSession(it) } + for (problem in response.problems) { + if (problem.isDeleted) { + val serverUpdate = parseISO8601(problem.updatedAt) ?: Instant.MAX + val existing = repository.getProblemById(problem.id) + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate) || serverUpdate == localUpdate) { + repository.deleteProblem(existing) + } } + continue + } - "attempt" -> { - existingAttempts.find { it.id == item.id }?.let { repository.deleteAttempt(it) } + var problemToMerge = problem + if (imagePathMapping.isNotEmpty() && !problem.imagePaths.isNullOrEmpty()) { + val updatedPaths = problem.imagePaths.map { imagePathMapping[it.substringAfterLast('/')] ?: it } + problemToMerge = problem.withUpdatedImagePaths(updatedPaths) + } + + val existing = repository.getProblemById(problem.id) + val serverUpdate = parseISO8601(problem.updatedAt) ?: Instant.MIN + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate)) { + repository.updateProblem(problemToMerge.toProblem()) } + } else { + repository.insertProblem(problemToMerge.toProblem()) + } + } + + for (session in response.sessions) { + if (session.isDeleted) { + val serverUpdate = parseISO8601(session.updatedAt) ?: Instant.MAX + val existing = repository.getSessionById(session.id) + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate) || serverUpdate == localUpdate) { + repository.deleteSession(existing) + } + } + continue + } + + val existing = repository.getSessionById(session.id) + val serverUpdate = parseISO8601(session.updatedAt) ?: Instant.MIN + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate)) { + repository.updateSession(session.toClimbSession()) + } + } else { + repository.insertSession(session.toClimbSession()) + } + } + + for (attempt in response.attempts) { + if (attempt.isDeleted) { + val serverUpdate = parseISO8601(attempt.updatedAt ?: attempt.createdAt) ?: Instant.MAX + val existing = repository.getAttemptById(attempt.id) + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate) || serverUpdate == localUpdate) { + repository.deleteAttempt(existing) + } + } + continue + } + + val existing = repository.getAttemptById(attempt.id) + val serverUpdate = parseISO8601(attempt.updatedAt ?: attempt.createdAt) ?: Instant.MIN + if (existing != null) { + val localUpdate = parseISO8601(existing.updatedAt) ?: Instant.MIN + if (serverUpdate.isAfter(localUpdate)) { + repository.updateAttempt(attempt.toAttempt()) + } + } else { + repository.insertAttempt(attempt.toAttempt()) } } } private suspend fun syncModifiedImages(modifiedProblems: List) { - if (modifiedProblems.isEmpty()) return + for (problem in modifiedProblems) { + if (problem.isDeleted) continue + val paths = problem.imagePaths ?: continue + for (path in paths) { + val localFile = getLocalImageFile(path) + if (localFile != null && localFile.exists()) { + try { + uploadImage(path, localFile.readBytes()) + } catch (e: Exception) { + AppLogger.e(TAG, e) { "Failed to upload image $path: ${e.message}" } + } + } + } + } + } - AppLogger.d(TAG) { "Syncing images for ${modifiedProblems.size} modified problems" } + private fun getLocalImageFile(filename: String): File? { + val dir = context.getExternalFilesDir("images") ?: return null + return File(dir, filename) + } - for (backupProblem in modifiedProblems) { - backupProblem.imagePaths?.forEach { imagePath -> - val filename = imagePath.substringAfterLast('/') - uploadImage(imagePath, filename) - } - } + private fun saveImageLocally(filename: String, data: ByteArray) { + val dir = context.getExternalFilesDir("images") ?: return + if (!dir.exists()) dir.mkdirs() + File(dir, filename).writeBytes(data) } private suspend fun downloadData(): ClimbDataBackup { - val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .get() - .build() + val request = Request.Builder() + .url("$serverUrl/sync") + .header("Authorization", "Bearer $authToken") + .get() + .build() return withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body?.string() - if (!body.isNullOrEmpty()) { - json.decodeFromString(body) - } else { - ClimbDataBackup( - exportedAt = DateFormatUtils.nowISO8601(), - gyms = emptyList(), - problems = emptyList(), - sessions = emptyList(), - attempts = emptyList(), - ) - } - } else { - handleHttpError(response.code) - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 401) throw SyncException.Unauthorized + throw SyncException.ServerError(response.code) } - } catch (e: IOException) { - throw SyncException.NetworkError(e.message ?: "Network error") + val body = response.body?.string() ?: throw SyncException.InvalidResponse("Empty body") + json.decodeFromString(body) } } } private suspend fun uploadData(backup: ClimbDataBackup) { - val requestBody = - json.encodeToString(backup).toRequestBody("application/json".toMediaType()) - - val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .put(requestBody) - .build() + val requestBody = json.encodeToString(backup).toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/sync") + .header("Authorization", "Bearer $authToken") + .put(requestBody) + .build() withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - handleHttpError(response.code) - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 401) throw SyncException.Unauthorized + throw SyncException.ServerError(response.code) } - } catch (e: IOException) { - throw SyncException.NetworkError(e.message ?: "Network error") } } } private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map { - val imagePathMapping = mutableMapOf() - val totalImages = backup.problems.sumOf { it.imagePaths?.size ?: 0 } - AppLogger.d(TAG) { "Starting image download from server for $totalImages images" } - - withContext(Dispatchers.IO) { - backup.problems.forEach { problem -> - problem.imagePaths?.forEach { imagePath -> - val serverFilename = imagePath.substringAfterLast('/') - try { - val localImagePath = downloadImage(serverFilename) - if (localImagePath != null) { - imagePathMapping[imagePath] = localImagePath - } - } catch (_: SyncException.ImageNotFound) { - AppLogger.w(TAG) { "Image not found on server: $imagePath" } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.w(TAG) { "Failed to download image $imagePath: ${e.message}" } - } + val mapping = mutableMapOf() + for (problem in backup.problems) { + if (problem.isDeleted) continue + val paths = problem.imagePaths ?: continue + for ((index, path) in paths.withIndex()) { + val serverFilename = path.substringAfterLast('/') + try { + val imageData = downloadImage(serverFilename) + val localFilename = "${problem.id}_${index}.jpg" + saveImageLocally(localFilename, imageData) + mapping[serverFilename] = localFilename + } catch (e: Exception) { + Log.e(TAG, "Failed to sync image $serverFilename", e) } } } - return imagePathMapping + return mapping } - private suspend fun downloadImage(serverFilename: String): String? { - val request = - Request.Builder() - .url("$serverUrl/images/download?filename=$serverFilename") - .header("Authorization", "Bearer $authToken") - .build() + private suspend fun syncImagesToServer() { + val problems = repository.getAllProblemsSync() + for (problem in problems) { + for (path in problem.imagePaths) { + val file = getLocalImageFile(path) + if (file != null && file.exists()) { + try { + uploadImage(path, file.readBytes()) + } catch (e: Exception) { + Log.e(TAG, "Failed to upload image $path", e) + } + } + } + } + } + + private suspend fun downloadImage(filename: String): ByteArray { + val request = Request.Builder() + .url("$serverUrl/images/download?filename=$filename") + .header("Authorization", "Bearer $authToken") + .build() return withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body?.bytes()?.let { - ImageUtils.saveImageFromBytesWithFilename(context, it, serverFilename) - } - } else { - if (response.code == 404) throw SyncException.ImageNotFound - null - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 404) throw SyncException.ImageNotFound + throw SyncException.ServerError(response.code) } - } catch (e: IOException) { - AppLogger.e(TAG, e) { "Network error downloading image $serverFilename" } - null + response.body?.bytes() ?: throw SyncException.InvalidResponse("Empty image body") } } } - private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { - AppLogger.d(TAG) { "Starting image sync for backup with ${backup.problems.size} problems" } - withContext(Dispatchers.IO) { - backup.problems.forEach { problem -> - problem.imagePaths?.forEach { localPath -> - val filename = localPath.substringAfterLast('/') - uploadImage(localPath, filename) - } - } - } - } - - private suspend fun uploadImage(localPath: String, filename: String) { - val file = ImageUtils.getImageFile(context, localPath) - if (!file.exists()) { - AppLogger.w(TAG) { "Local image file not found, cannot upload: $localPath" } - return - } - - val requestBody = file.readBytes().toRequestBody("application/octet-stream".toMediaType()) - - val request = - Request.Builder() - .url("$serverUrl/images/upload?filename=$filename") - .header("Authorization", "Bearer $authToken") - .post(requestBody) - .build() + private suspend fun uploadImage(filename: String, data: ByteArray) { + val requestBody = data.toRequestBody("application/octet-stream".toMediaType()) + val request = Request.Builder() + .url("$serverUrl/images/upload?filename=$filename") + .header("Authorization", "Bearer $authToken") + .post(requestBody) + .build() withContext(Dispatchers.IO) { - try { - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - AppLogger.d(TAG) { "Successfully uploaded image: $filename" } - } else { - AppLogger.w(TAG) { - "Failed to upload image $filename. Server responded with ${response.code}" - } - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw SyncException.ServerError(response.code) } - } catch (e: IOException) { - AppLogger.e(TAG, e) { "Network error uploading image $filename" } } } } private suspend fun createBackupFromRepository(): ClimbDataBackup { - return withContext(Dispatchers.Default) { - ClimbDataBackup( - exportedAt = dataStateManager.getLastModified(), - gyms = repository.getAllGyms().first().map { BackupGym.fromGym(it) }, - problems = - repository.getAllProblems().first().map { problem -> - val backupProblem = BackupProblem.fromProblem(problem) - val normalizedImagePaths = - problem.imagePaths.mapIndexed { index, _ -> - ImageNamingUtils.generateImageFilename( - problem.id, - index, - ) - } - if (normalizedImagePaths.isNotEmpty()) { - backupProblem.copy(imagePaths = normalizedImagePaths) - } else { - backupProblem - } - }, - sessions = - repository.getAllSessions().first().map { - BackupClimbSession.fromClimbSession(it) - }, - attempts = - repository.getAllAttempts().first().map { - BackupAttempt.fromAttempt(it) - }, - deletedItems = repository.getDeletedItems(), - ) - } + val gyms = repository.getAllGymsSync().map { BackupGym.fromGym(it) } + val problems = repository.getAllProblemsSync().map { BackupProblem.fromProblem(it) } + val sessions = repository.getAllSessionsSync().map { BackupClimbSession.fromClimbSession(it) } + val attempts = repository.getAllAttemptsSync().map { BackupAttempt.fromAttempt(it) } + + return ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + gyms = gyms, + problems = problems, + sessions = sessions, + attempts = attempts + ) } - private suspend fun importBackupToRepository( - backup: ClimbDataBackup, - imagePathMapping: Map, - ) { - val gyms = backup.gyms.map { it.toGym() } - val problems = - backup.problems.map { backupProblem -> - val imagePaths = backupProblem.imagePaths - val updatedImagePaths = - imagePaths?.map { oldPath -> imagePathMapping[oldPath] ?: oldPath } - backupProblem.copy(imagePaths = updatedImagePaths).toProblem() + private suspend fun importBackupToRepository(backup: ClimbDataBackup, imagePathMapping: Map) { + for (gym in backup.gyms) { + if (!gym.isDeleted) repository.insertGym(gym.toGym()) + } + + for (problem in backup.problems) { + if (!problem.isDeleted) { + var p = problem + if (imagePathMapping.isNotEmpty() && !problem.imagePaths.isNullOrEmpty()) { + val updatedPaths = problem.imagePaths.map { imagePathMapping[it.substringAfterLast('/')] ?: it } + p = problem.withUpdatedImagePaths(updatedPaths) + } + repository.insertProblem(p.toProblem()) } - val sessions = backup.sessions.map { it.toClimbSession() } - val attempts = backup.attempts.map { it.toAttempt() } - - repository.resetAllData() - - gyms.forEach { repository.insertGymWithoutSync(it) } - problems.forEach { repository.insertProblemWithoutSync(it) } - sessions.forEach { repository.insertSessionWithoutSync(it) } - attempts.forEach { repository.insertAttemptWithoutSync(it) } - - repository.clearDeletedItems() - } - - private suspend fun mergeDataSafely(serverBackup: ClimbDataBackup) { - AppLogger.d(TAG) { "Server data will overwrite local data. Performing full restore." } - val imagePathMapping = syncImagesFromServer(serverBackup) - importBackupToRepository(serverBackup, imagePathMapping) - } - - private fun handleHttpError(code: Int): Nothing { - when (code) { - 401 -> throw SyncException.Unauthorized - in 500..599 -> throw SyncException.ServerError(code) - else -> throw SyncException.InvalidResponse("HTTP error code: $code") } + + for (session in backup.sessions) { + if (!session.isDeleted) repository.insertSession(session.toClimbSession()) + } + + for (attempt in backup.attempts) { + if (!attempt.isDeleted) repository.insertAttempt(attempt.toAttempt()) + } + } + + private suspend fun mergeDataSafely(local: ClimbDataBackup, server: ClimbDataBackup) { + val deltaResponse = DeltaSyncResponse( + serverTime = server.exportedAt, + gyms = server.gyms, + problems = server.problems, + sessions = server.sessions, + attempts = server.attempts, + ) + applyDeltaResponse(deltaResponse) } override suspend fun testConnection() { - if (!_isConfigured.value) { - _isConnected.value = false - throw SyncException.NotConfigured - } + if (!isConfigured.value) throw SyncException.NotConfigured + + val request = Request.Builder() + .url("$serverUrl/health") + .apply { + if (authToken.isNotBlank()) header("Authorization", "Bearer $authToken") + } + .head() // Using HEAD as originally, or GET + .build() - val request = - Request.Builder() - .url("$serverUrl/sync") - .header("Authorization", "Bearer $authToken") - .head() - .build() try { withContext(Dispatchers.IO) { httpClient.newCall(request).execute().use { response -> - _isConnected.value = response.isSuccessful || response.code == 405 + if (!response.isSuccessful && response.code != 405) { + throw SyncException.ServerError(response.code) + } + _isConnected.value = true + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, true).apply() } } - if (!_isConnected.value) { - throw SyncException.NotConnected - } } catch (e: Exception) { _isConnected.value = false - throw SyncException.NetworkError(e.message ?: "Connection error") - } finally { - sharedPreferences.edit { putBoolean(Keys.IS_CONNECTED, _isConnected.value) } + throw e } } } diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/DeltaSyncModels.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/DeltaSyncModels.kt index 495452d..473a1d7 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/DeltaSyncModels.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/DeltaSyncModels.kt @@ -4,7 +4,6 @@ import com.atridad.ascently.data.format.BackupAttempt import com.atridad.ascently.data.format.BackupClimbSession import com.atridad.ascently.data.format.BackupGym import com.atridad.ascently.data.format.BackupProblem -import com.atridad.ascently.data.format.DeletedItem import kotlinx.serialization.Serializable /** Request structure for delta sync - sends only changes since last sync */ @@ -15,16 +14,15 @@ data class DeltaSyncRequest( val problems: List, val sessions: List, val attempts: List, - val deletedItems: List, ) /** Response structure for delta sync - receives only changes from server */ @Serializable data class DeltaSyncResponse( val serverTime: String, + val requestFullSync: Boolean = false, val gyms: List, val problems: List, val sessions: List, val attempts: List, - val deletedItems: List, ) diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt index 040e470..ff454c1 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncException.kt @@ -18,4 +18,6 @@ sealed class SyncException(message: String) : IOException(message), Serializable SyncException("Invalid server response: $details") data class NetworkError(val details: String) : SyncException("Network error: $details") + + data class General(val details: String) : SyncException(details) } diff --git a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt index 2c68e3a..27c9c9f 100644 --- a/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/ascently/data/sync/SyncService.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.atridad.ascently.data.repository.ClimbRepository +import com.atridad.ascently.data.state.DataStateManager import com.atridad.ascently.utils.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,7 +28,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep } // Currently we only support one provider, but this allows for future expansion - private val provider: SyncProvider = AscentlySyncProvider(context, repository) + private val provider: SyncProvider = AscentlySyncProvider(context, repository, DataStateManager(context)) // State private val _isSyncing = MutableStateFlow(false) diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index cdddfec..51b5031 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -466,7 +466,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -518,7 +518,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -610,7 +610,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -641,7 +641,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 43; + CURRENT_PROJECT_VERSION = 44; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 424d3b0..c926ae3 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Models/BackupFormat.swift b/ios/Ascently/Models/BackupFormat.swift index 44ec1fc..f923ada 100644 --- a/ios/Ascently/Models/BackupFormat.swift +++ b/ios/Ascently/Models/BackupFormat.swift @@ -388,7 +388,7 @@ struct BackupClimbSession: Codable { startTime: nil, endTime: nil, duration: nil, - status: .finished, + status: .completed, notes: nil, isDeleted: true, createdAt: dateString, diff --git a/ios/Ascently/Models/DeltaSyncFormat.swift b/ios/Ascently/Models/DeltaSyncFormat.swift index 9277f79..f812f4f 100644 --- a/ios/Ascently/Models/DeltaSyncFormat.swift +++ b/ios/Ascently/Models/DeltaSyncFormat.swift @@ -17,6 +17,7 @@ struct DeltaSyncRequest: Codable { struct DeltaSyncResponse: Codable { let serverTime: String + let requestFullSync: Bool? let gyms: [BackupGym] let problems: [BackupProblem] let sessions: [BackupClimbSession] diff --git a/ios/Ascently/Services/Sync/ServerSyncProvider.swift b/ios/Ascently/Services/Sync/ServerSyncProvider.swift index 36ccb0b..21c9ade 100644 --- a/ios/Ascently/Services/Sync/ServerSyncProvider.swift +++ b/ios/Ascently/Services/Sync/ServerSyncProvider.swift @@ -71,8 +71,6 @@ class ServerSyncProvider: SyncProvider { throw SyncError.notConnected } - // 1. Priority: Delta Sync - // If we have synced before, assume we want to continue with delta sync if lastSyncTime != nil { AppLogger.info("Last sync time found, attempting delta sync", tag: logTag) do { @@ -81,18 +79,12 @@ class ServerSyncProvider: SyncProvider { return } catch { AppLogger.error("Delta sync failed, falling back to full sync check: \(error)", tag: logTag) - // Fallthrough to full sync logic } } - // 2. Full Sync Logic - // Get local backup data let localBackup = createBackupFromDataManager(dataManager) - - // Download server data let serverBackup = try await downloadData() - // Check if we have any local data let hasLocalData = !dataManager.gyms.isEmpty || !dataManager.problems.isEmpty || !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty @@ -219,7 +211,6 @@ class ServerSyncProvider: SyncProvider { let formatter = ISO8601DateFormatter() let lastSyncString = formatter.string(from: lastSync) - // Collect items modified since last sync var modifiedGyms = dataManager.gyms.filter { gym in gym.updatedAt > lastSync }.map { BackupGym(from: $0) } @@ -249,7 +240,6 @@ class ServerSyncProvider: SyncProvider { !activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync }.map { BackupAttempt(from: $0) } - // Handle deleted items as tombstones let deletedItems = dataManager.getDeletedItems().filter { item in if let deletedDate = formatter.date(from: item.deletedAt) { return deletedDate > lastSync @@ -316,6 +306,11 @@ class ServerSyncProvider: SyncProvider { let decoder = JSONDecoder() let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) + if let requestFullSync = deltaResponse.requestFullSync, requestFullSync { + AppLogger.info("Server requested full sync", tag: logTag) + throw SyncError.serverError(412) + } + AppLogger.info( "Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count)", tag: logTag @@ -337,7 +332,6 @@ class ServerSyncProvider: SyncProvider { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // 1. Download images for problems that are NOT deleted var imagePathMapping: [String: String] = [:] for problem in response.problems { if let isDeleted = problem.isDeleted, isDeleted { continue } @@ -359,9 +353,7 @@ class ServerSyncProvider: SyncProvider { } } - // 2. Merge Gyms for backupGym in response.gyms { - // Handle Soft Delete if let isDeleted = backupGym.isDeleted, isDeleted { if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { let existing = dataManager.gyms[index] @@ -373,7 +365,6 @@ class ServerSyncProvider: SyncProvider { continue } - // Handle Update/Insert if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { let existing = dataManager.gyms[index] if let serverUpdate = formatter.date(from: backupGym.updatedAt), @@ -385,7 +376,6 @@ class ServerSyncProvider: SyncProvider { } } - // 3. Merge Problems for backupProblem in response.problems { if let isDeleted = backupProblem.isDeleted, isDeleted { if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == backupProblem.id }) { @@ -415,7 +405,6 @@ class ServerSyncProvider: SyncProvider { } } - // 4. Merge Sessions for backupSession in response.sessions { if let isDeleted = backupSession.isDeleted, isDeleted { if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { @@ -439,7 +428,6 @@ class ServerSyncProvider: SyncProvider { } } - // 5. Merge Attempts for backupAttempt in response.attempts { if let isDeleted = backupAttempt.isDeleted, isDeleted { if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { @@ -477,7 +465,7 @@ class ServerSyncProvider: SyncProvider { for problem in modifiedProblems { guard let imagePaths = problem.imagePaths else { continue } for path in imagePaths { - if let data = ImageManager.shared.getImageData(filename: path) { + if let data = ImageManager.shared.loadImageData(fromPath: path) { try await uploadImage(filename: path, imageData: data) } } @@ -549,7 +537,7 @@ class ServerSyncProvider: SyncProvider { private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { for problem in dataManager.problems { for path in problem.imagePaths { - if let data = ImageManager.shared.getImageData(filename: path) { + if let data = ImageManager.shared.loadImageData(fromPath: path) { try await uploadImage(filename: path, imageData: data) } } @@ -568,19 +556,15 @@ class ServerSyncProvider: SyncProvider { gyms: gyms, problems: problems, sessions: sessions, - attempts: attempts, - deletedItems: [] // Legacy field, empty + attempts: attempts ) } private func mergeDataSafely(localBackup: ClimbDataBackup, serverBackup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws { - // Basic full merge that prefers server data if newer let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Merging Gyms for gym in serverBackup.gyms { - // Check for soft delete if let isDeleted = gym.isDeleted, isDeleted { if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) { let existing = dataManager.gyms[index] @@ -591,7 +575,6 @@ class ServerSyncProvider: SyncProvider { continue } - // Update or Insert if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) { let existing = dataManager.gyms[index] if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt { @@ -602,7 +585,6 @@ class ServerSyncProvider: SyncProvider { } } - // Merging Problems for problem in serverBackup.problems { if let isDeleted = problem.isDeleted, isDeleted { if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) { @@ -624,7 +606,6 @@ class ServerSyncProvider: SyncProvider { } } - // Merging Sessions for session in serverBackup.sessions { if let isDeleted = session.isDeleted, isDeleted { if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) { @@ -646,7 +627,6 @@ class ServerSyncProvider: SyncProvider { } } - // Merging Attempts for attempt in serverBackup.attempts { if let isDeleted = attempt.isDeleted, isDeleted { if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) { @@ -680,7 +660,6 @@ class ServerSyncProvider: SyncProvider { _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, imagePathMapping: [String: String] = [:] ) throws { - // Logic from previous read let updatedProblems = backup.problems.map { problem in let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in imagePathMapping[oldPath] ?? oldPath @@ -688,7 +667,6 @@ class ServerSyncProvider: SyncProvider { return problem.withUpdatedImagePaths(updatedImagePaths) } - // Re-construct data, filtering out deleted items (tombstones) dataManager.gyms = try backup.gyms.compactMap { gym in if let isDeleted = gym.isDeleted, isDeleted { return nil } return try gym.toGym() diff --git a/ios/Ascently/Services/Sync/SyncMerger.swift b/ios/Ascently/Services/Sync/SyncMerger.swift deleted file mode 100644 index 8600400..0000000 --- a/ios/Ascently/Services/Sync/SyncMerger.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation - -struct SyncMerger { - private static let logTag = "SyncMerger" - - static func mergeDataSafely( - localBackup: ClimbDataBackup, - serverBackup: ClimbDataBackup, - dataManager: ClimbingDataManager, - imagePathMapping: [String: String] - ) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) { - - // Merge deletion lists first to prevent resurrection of deleted items - let localDeletions = dataManager.getDeletedItems() - let allDeletions = localDeletions + serverBackup.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - - AppLogger.info("Merging gyms...", tag: logTag) - let mergedGyms = mergeGyms( - local: dataManager.gyms, - server: serverBackup.gyms, - deletedItems: uniqueDeletions) - - AppLogger.info("Merging problems...", tag: logTag) - let mergedProblems = try mergeProblems( - local: dataManager.problems, - server: serverBackup.problems, - imagePathMapping: imagePathMapping, - deletedItems: uniqueDeletions) - - AppLogger.info("Merging sessions...", tag: logTag) - let mergedSessions = try mergeSessions( - local: dataManager.sessions, - server: serverBackup.sessions, - deletedItems: uniqueDeletions) - - AppLogger.info("Merging attempts...", tag: logTag) - let mergedAttempts = try mergeAttempts( - local: dataManager.attempts, - server: serverBackup.attempts, - deletedItems: uniqueDeletions) - - return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions) - } - - private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] { - var merged = local - let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id }) - let localGymIds = Set(local.map { $0.id.uuidString }) - - 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 = localGymIds.contains(serverGym.id) - let isDeleted = deletedGymIds.contains(serverGym.id) - - if !localHasGym && !isDeleted { - merged.append(serverGymConverted) - } - } - } - - return merged - } - - private static 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 }) - let localProblemIds = Set(local.map { $0.id.uuidString }) - - merged.removeAll { deletedProblemIds.contains($0.id.uuidString) } - - for serverProblem in server { - let localHasProblem = localProblemIds.contains(serverProblem.id) - let isDeleted = deletedProblemIds.contains(serverProblem.id) - - if !localHasProblem && !isDeleted { - var problemToAdd = serverProblem - - if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty { - let updatedImagePaths = imagePaths.compactMap { oldPath in - imagePathMapping[oldPath] ?? oldPath - } - if updatedImagePaths != imagePaths { - 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() { - merged.append(serverProblemConverted) - } - } - } - - return merged - } - - private static func mergeSessions( - local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem] - ) throws -> [ClimbSession] { - var merged = local - let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id }) - let localSessionIds = Set(local.map { $0.id.uuidString }) - - merged.removeAll { session in - deletedSessionIds.contains(session.id.uuidString) && session.status != .active - } - - for serverSession in server { - let localHasSession = localSessionIds.contains(serverSession.id) - let isDeleted = deletedSessionIds.contains(serverSession.id) - - if !localHasSession && !isDeleted { - if let serverSessionConverted = try? serverSession.toClimbSession() { - merged.append(serverSessionConverted) - } - } - } - - return merged - } - - private static func mergeAttempts( - local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem] - ) throws -> [Attempt] { - var merged = local - let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id }) - let localAttemptIds = Set(local.map { $0.id.uuidString }) - - // Get active session IDs to protect their attempts - let activeSessionIds = Set( - local.compactMap { attempt in - return attempt.sessionId - }.filter { _ in - 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) - } - - for serverAttempt in server { - let localHasAttempt = localAttemptIds.contains(serverAttempt.id) - let isDeleted = deletedAttemptIds.contains(serverAttempt.id) - - if !localHasAttempt && !isDeleted { - if let serverAttemptConverted = try? serverAttempt.toAttempt() { - merged.append(serverAttemptConverted) - } - } - } - - return merged - } -} diff --git a/ios/Ascently/ViewModels/ClimbingDataManager.swift b/ios/Ascently/ViewModels/ClimbingDataManager.swift index f44f31c..20deb6e 100644 --- a/ios/Ascently/ViewModels/ClimbingDataManager.swift +++ b/ios/Ascently/ViewModels/ClimbingDataManager.swift @@ -631,6 +631,34 @@ class ClimbingDataManager: ObservableObject { userDefaults.removeObject(forKey: Keys.deletedItems) } + func cleanupOldDeletions() { + guard let data = userDefaults.data(forKey: Keys.deletedItems), + let deletions = try? decoder.decode([DeletedItem].self, from: data) + else { + return + } + + let cutoffDate = Date().addingTimeInterval(-90 * 24 * 60 * 60) // 90 days ago + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + let validDeletions = deletions.filter { item in + if let date = formatter.date(from: item.deletedAt) { + return date > cutoffDate + } + return false + } + + if validDeletions.count < deletions.count { + if let encodedData = try? encoder.encode(validDeletions) { + userDefaults.set(encodedData, forKey: Keys.deletedItems) + AppLogger.info( + "Cleaned up \(deletions.count - validDeletions.count) old deletion records", + tag: "ClimbingDataManager") + } + } + } + func attempts(forProblem problemId: UUID) -> [Attempt] { return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp } } @@ -669,6 +697,7 @@ class ClimbingDataManager: ObservableObject { } private func cleanupOrphanedData() { + cleanupOldDeletions() let validSessionIds = Set(sessions.map { $0.id }) let validProblemIds = Set(problems.map { $0.id }) let validGymIds = Set(gyms.map { $0.id }) diff --git a/sync/go.mod b/sync/go.mod index 3bb9e3e..7d0ed6b 100644 --- a/sync/go.mod +++ b/sync/go.mod @@ -1,3 +1,5 @@ module ascently-sync go 1.25 + +require github.com/joho/godotenv v1.5.1 diff --git a/sync/go.sum b/sync/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/sync/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/sync/main.go b/sync/main.go index 12d44e1..64b2c68 100644 --- a/sync/main.go +++ b/sync/main.go @@ -11,16 +11,11 @@ import ( "path/filepath" "strings" "time" + + "github.com/joho/godotenv" ) -const VERSION = "2.4.0" - -func min(a, b int) int { - if a < b { - return a - } - return b -} +const VERSION = "2.5.0" type ClimbDataBackup struct { ExportedAt string `json:"exportedAt"` @@ -41,11 +36,12 @@ type DeltaSyncRequest struct { } type DeltaSyncResponse struct { - ServerTime string `json:"serverTime"` - Gyms []BackupGym `json:"gyms"` - Problems []BackupProblem `json:"problems"` - Sessions []BackupClimbSession `json:"sessions"` - Attempts []BackupAttempt `json:"attempts"` + ServerTime string `json:"serverTime"` + RequestFullSync bool `json:"requestFullSync,omitempty"` + Gyms []BackupGym `json:"gyms"` + Problems []BackupProblem `json:"problems"` + Sessions []BackupClimbSession `json:"sessions"` + Attempts []BackupAttempt `json:"attempts"` } type BackupGym struct { @@ -282,6 +278,81 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt return result } +func (s *SyncServer) cleanupTombstones(backup *ClimbDataBackup) { + cutoffTime := time.Now().UTC().Add(-90 * 24 * time.Hour) + log.Printf("Cleaning up tombstones older than %s", cutoffTime.Format(time.RFC3339)) + + // Gyms + activeGyms := make([]BackupGym, 0, len(backup.Gyms)) + for _, item := range backup.Gyms { + if !item.IsDeleted { + activeGyms = append(activeGyms, item) + continue + } + updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) + if err == nil && updatedAt.After(cutoffTime) { + activeGyms = append(activeGyms, item) + } else { + log.Printf("Pruning deleted gym: %s", item.ID) + } + } + backup.Gyms = activeGyms + + // Problems + activeProblems := make([]BackupProblem, 0, len(backup.Problems)) + for _, item := range backup.Problems { + if !item.IsDeleted { + activeProblems = append(activeProblems, item) + continue + } + updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) + if err == nil && updatedAt.After(cutoffTime) { + activeProblems = append(activeProblems, item) + } else { + log.Printf("Pruning deleted problem: %s", item.ID) + } + } + backup.Problems = activeProblems + + // Sessions + activeSessions := make([]BackupClimbSession, 0, len(backup.Sessions)) + for _, item := range backup.Sessions { + if !item.IsDeleted { + activeSessions = append(activeSessions, item) + continue + } + updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) + if err == nil && updatedAt.After(cutoffTime) { + activeSessions = append(activeSessions, item) + } else { + log.Printf("Pruning deleted session: %s", item.ID) + } + } + backup.Sessions = activeSessions + + // Attempts + activeAttempts := make([]BackupAttempt, 0, len(backup.Attempts)) + for _, item := range backup.Attempts { + if !item.IsDeleted { + activeAttempts = append(activeAttempts, item) + continue + } + + timeStr := item.CreatedAt + if item.UpdatedAt != nil { + timeStr = *item.UpdatedAt + } + + updatedAt, err := time.Parse(time.RFC3339, timeStr) + if err == nil && updatedAt.After(cutoffTime) { + activeAttempts = append(activeAttempts, item) + } else { + log.Printf("Pruning deleted attempt: %s", item.ID) + } + } + backup.Attempts = activeAttempts +} + func (s *SyncServer) saveData(backup *ClimbDataBackup) error { backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) @@ -339,6 +410,8 @@ func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) { return } + s.cleanupTombstones(&backup) + if err := s.saveData(&backup); err != nil { log.Printf("Failed to save data: %v", err) http.Error(w, "Failed to save data", http.StatusInternalServerError) @@ -476,14 +549,33 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } + clientLastSyncCheck, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime) + isServerEmpty := len(serverBackup.Gyms) == 0 && len(serverBackup.Problems) == 0 && + len(serverBackup.Sessions) == 0 && len(serverBackup.Attempts) == 0 + + if err == nil && !clientLastSyncCheck.IsZero() && isServerEmpty { + log.Printf("Server is empty but client has sync history. Requesting full sync.") + response := DeltaSyncResponse{ + ServerTime: time.Now().UTC().Format(time.RFC3339), + RequestFullSync: true, + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + // Merge client changes into server data - // Note: We no longer need separate deletion handling as IsDeleted is part of the struct - // and handled by standard merge logic (latest timestamp wins) serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms) serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems) serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions) serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts) + s.cleanupTombstones(serverBackup) + // Save merged data if err := s.saveData(serverBackup); err != nil { log.Printf("Failed to save data: %v", err) @@ -565,7 +657,9 @@ func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) { } func main() { + godotenv.Load() authToken := os.Getenv("AUTH_TOKEN") + print(authToken) if authToken == "" { log.Fatal("AUTH_TOKEN environment variable is required") } diff --git a/sync/sync_test.go b/sync/sync_test.go deleted file mode 100644 index f0b0161..0000000 --- a/sync/sync_test.go +++ /dev/null @@ -1,501 +0,0 @@ -package main - -import ( - "path/filepath" - "testing" - "time" -) - -// TestDeltaSyncDeletedItemResurrection verifies deleted items don't resurrect -func TestDeltaSyncDeletedItemResurrection(t *testing.T) { - tempDir := t.TempDir() - server := &SyncServer{ - dataFile: filepath.Join(tempDir, "test.json"), - imagesDir: filepath.Join(tempDir, "images"), - authToken: "test-token", - } - - // Initial state: Server has one gym, one problem, one session with 8 attempts - now := time.Now().UTC() - gymID := "gym-1" - problemID := "problem-1" - sessionID := "session-1" - - initialBackup := &ClimbDataBackup{ - Version: "2.0", - FormatVersion: "2.0", - Gyms: []BackupGym{ - { - ID: gymID, - Name: "Test Gym", - SupportedClimbTypes: []string{"BOULDER"}, - DifficultySystems: []string{"V"}, - CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), - UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), - }, - }, - Problems: []BackupProblem{ - { - ID: problemID, - GymID: gymID, - ClimbType: "BOULDER", - Difficulty: DifficultyGrade{ - System: "V", - Grade: "V5", - NumericValue: 5, - }, - IsActive: true, - CreatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), - UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), - }, - }, - Sessions: []BackupClimbSession{ - { - ID: sessionID, - GymID: gymID, - Date: now.Format("2006-01-02"), - Status: "completed", - CreatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339), - UpdatedAt: now.Add(-30 * time.Minute).Format(time.RFC3339), - }, - }, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - // Add 8 attempts - for i := 0; i < 8; i++ { - attempt := BackupAttempt{ - ID: "attempt-" + string(rune('1'+i)), - SessionID: sessionID, - ProblemID: problemID, - Result: "COMPLETED", - Timestamp: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339), - CreatedAt: now.Add(time.Duration(-25+i) * time.Minute).Format(time.RFC3339), - } - initialBackup.Attempts = append(initialBackup.Attempts, attempt) - } - - if err := server.saveData(initialBackup); err != nil { - t.Fatalf("Failed to save initial data: %v", err) - } - - // Client 1 syncs - gets all data - client1LastSync := now.Add(-2 * time.Hour).Format(time.RFC3339) - deltaRequest1 := DeltaSyncRequest{ - LastSyncTime: client1LastSync, - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - // Simulate delta sync for client 1 - serverBackup, _ := server.loadData() - serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest1.DeletedItems) - server.applyDeletions(serverBackup, serverBackup.DeletedItems) - - if len(serverBackup.Sessions) != 1 { - t.Errorf("Expected 1 session after client1 sync, got %d", len(serverBackup.Sessions)) - } - if len(serverBackup.Attempts) != 8 { - t.Errorf("Expected 8 attempts after client1 sync, got %d", len(serverBackup.Attempts)) - } - - // Client 1 deletes the session locally - deleteTime := now.Format(time.RFC3339) - deletions := []DeletedItem{ - {ID: sessionID, Type: "session", DeletedAt: deleteTime}, - } - // Also track attempt deletions - for _, attempt := range initialBackup.Attempts { - deletions = append(deletions, DeletedItem{ - ID: attempt.ID, - Type: "attempt", - DeletedAt: deleteTime, - }) - } - - // Client 1 syncs deletion - deltaRequest2 := DeltaSyncRequest{ - LastSyncTime: now.Add(-5 * time.Minute).Format(time.RFC3339), - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: deletions, - } - - // Server processes deletion - serverBackup, _ = server.loadData() - serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest2.DeletedItems) - server.applyDeletions(serverBackup, serverBackup.DeletedItems) - server.saveData(serverBackup) - - // Verify deletions were applied on server - serverBackup, _ = server.loadData() - if len(serverBackup.Sessions) != 0 { - t.Errorf("Expected 0 sessions after deletion, got %d", len(serverBackup.Sessions)) - } - if len(serverBackup.Attempts) != 0 { - t.Errorf("Expected 0 attempts after deletion, got %d", len(serverBackup.Attempts)) - } - if len(serverBackup.DeletedItems) != 9 { - t.Errorf("Expected 9 deletion records, got %d", len(serverBackup.DeletedItems)) - } - - // Client does local reset and pulls from server - deltaRequest3 := DeltaSyncRequest{ - LastSyncTime: time.Time{}.Format(time.RFC3339), - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - serverBackup, _ = server.loadData() - clientLastSync, _ := time.Parse(time.RFC3339, deltaRequest3.LastSyncTime) - - // Build response - response := DeltaSyncResponse{ - ServerTime: time.Now().UTC().Format(time.RFC3339), - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - // Build deleted item map - deletedItemMap := make(map[string]bool) - for _, item := range serverBackup.DeletedItems { - key := item.Type + ":" + item.ID - deletedItemMap[key] = true - } - - // Filter sessions (excluding deleted) - for _, session := range serverBackup.Sessions { - if deletedItemMap["session:"+session.ID] { - continue - } - sessionTime, _ := time.Parse(time.RFC3339, session.UpdatedAt) - if sessionTime.After(clientLastSync) { - response.Sessions = append(response.Sessions, session) - } - } - - // Filter attempts (excluding deleted) - for _, attempt := range serverBackup.Attempts { - if deletedItemMap["attempt:"+attempt.ID] { - continue - } - attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt) - if attemptTime.After(clientLastSync) { - response.Attempts = append(response.Attempts, attempt) - } - } - - // Send deletion records - for _, deletion := range serverBackup.DeletedItems { - deletionTime, _ := time.Parse(time.RFC3339, deletion.DeletedAt) - if deletionTime.After(clientLastSync) { - response.DeletedItems = append(response.DeletedItems, deletion) - } - } - - if len(response.Sessions) != 0 { - t.Errorf("Deleted session was resurrected! Got %d sessions in response", len(response.Sessions)) - } - if len(response.Attempts) != 0 { - t.Errorf("Deleted attempts were resurrected! Got %d attempts in response", len(response.Attempts)) - } - if len(response.DeletedItems) < 9 { - t.Errorf("Expected at least 9 deletion records in response, got %d", len(response.DeletedItems)) - } -} - -// TestDeltaSyncAttemptCount verifies all attempts are preserved -func TestDeltaSyncAttemptCount(t *testing.T) { - tempDir := t.TempDir() - server := &SyncServer{ - dataFile: filepath.Join(tempDir, "test.json"), - imagesDir: filepath.Join(tempDir, "images"), - authToken: "test-token", - } - - now := time.Now().UTC() - gymID := "gym-1" - problemID := "problem-1" - sessionID := "session-1" - - // Create session with 8 attempts - initialBackup := &ClimbDataBackup{ - Version: "2.0", - FormatVersion: "2.0", - Gyms: []BackupGym{{ID: gymID, Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Problems: []BackupProblem{{ID: problemID, GymID: gymID, ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Sessions: []BackupClimbSession{{ID: sessionID, GymID: gymID, Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - // Add 8 attempts at different times - baseTime := now.Add(-30 * time.Minute) - for i := 0; i < 8; i++ { - attempt := BackupAttempt{ - ID: "attempt-" + string(rune('1'+i)), - SessionID: sessionID, - ProblemID: problemID, - Result: "COMPLETED", - Timestamp: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), - CreatedAt: baseTime.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), - } - initialBackup.Attempts = append(initialBackup.Attempts, attempt) - } - - if err := server.saveData(initialBackup); err != nil { - t.Fatalf("Failed to save initial data: %v", err) - } - - // Client syncs with lastSyncTime BEFORE all attempts were created - clientLastSync := baseTime.Add(-1 * time.Hour) - - serverBackup, _ := server.loadData() - - // Count attempts that should be returned - attemptCount := 0 - for _, attempt := range serverBackup.Attempts { - attemptTime, _ := time.Parse(time.RFC3339, attempt.CreatedAt) - if attemptTime.After(clientLastSync) { - attemptCount++ - } - } - - if attemptCount != 8 { - t.Errorf("Expected all 8 attempts to be returned, got %d", attemptCount) - } - -} - -// TestTombstoneCleanup verifies old deletion records are cleaned up -func TestTombstoneCleanup(t *testing.T) { - server := &SyncServer{} - - now := time.Now().UTC() - oldDeletion := DeletedItem{ - ID: "old-item", - Type: "session", - DeletedAt: now.Add(-31 * 24 * time.Hour).Format(time.RFC3339), // 31 days old - } - recentDeletion := DeletedItem{ - ID: "recent-item", - Type: "session", - DeletedAt: now.Add(-1 * 24 * time.Hour).Format(time.RFC3339), // 1 day old - } - - existing := []DeletedItem{oldDeletion} - updates := []DeletedItem{recentDeletion} - - merged := server.mergeDeletedItems(existing, updates) - - // Old deletion should be cleaned up, only recent one remains - if len(merged) != 1 { - t.Errorf("Expected 1 deletion record after cleanup, got %d", len(merged)) - } - if len(merged) > 0 && merged[0].ID != "recent-item" { - t.Errorf("Expected recent deletion to remain, got %s", merged[0].ID) - } - -} - -// TestMergeDeletedItemsDeduplication verifies duplicate deletions are handled -func TestMergeDeletedItemsDeduplication(t *testing.T) { - server := &SyncServer{} - - now := time.Now().UTC() - deletion1 := DeletedItem{ - ID: "item-1", - Type: "session", - DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339), - } - deletion2 := DeletedItem{ - ID: "item-1", - Type: "session", - DeletedAt: now.Format(time.RFC3339), // Newer timestamp - } - - existing := []DeletedItem{deletion1} - updates := []DeletedItem{deletion2} - - merged := server.mergeDeletedItems(existing, updates) - - if len(merged) != 1 { - t.Errorf("Expected 1 deletion record, got %d", len(merged)) - } - if len(merged) > 0 && merged[0].DeletedAt != deletion2.DeletedAt { - t.Errorf("Expected newer deletion timestamp to be kept") - } - -} - -// TestApplyDeletions verifies deletions are applied correctly -func TestApplyDeletions(t *testing.T) { - server := &SyncServer{} - - now := time.Now().UTC() - backup := &ClimbDataBackup{ - Version: "2.0", - FormatVersion: "2.0", - Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Attempts: []BackupAttempt{{ID: "attempt-1", SessionID: "session-1", ProblemID: "problem-1", Result: "COMPLETED", Timestamp: now.Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339)}}, - DeletedItems: []DeletedItem{}, - } - - deletions := []DeletedItem{ - {ID: "session-1", Type: "session", DeletedAt: now.Format(time.RFC3339)}, - {ID: "attempt-1", Type: "attempt", DeletedAt: now.Format(time.RFC3339)}, - } - - server.applyDeletions(backup, deletions) - - if len(backup.Sessions) != 0 { - t.Errorf("Expected 0 sessions after deletion, got %d", len(backup.Sessions)) - } - if len(backup.Attempts) != 0 { - t.Errorf("Expected 0 attempts after deletion, got %d", len(backup.Attempts)) - } - if len(backup.Gyms) != 1 { - t.Errorf("Expected gym to remain, got %d gyms", len(backup.Gyms)) - } - if len(backup.Problems) != 1 { - t.Errorf("Expected problem to remain, got %d problems", len(backup.Problems)) - } - -} - -// TestCascadingDeletions verifies related items are handled properly -func TestCascadingDeletions(t *testing.T) { - server := &SyncServer{} - - now := time.Now().UTC() - sessionID := "session-1" - backup := &ClimbDataBackup{ - Version: "2.0", - FormatVersion: "2.0", - Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{}, DifficultySystems: []string{}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Sessions: []BackupClimbSession{{ID: sessionID, GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - - // Add multiple attempts for the session - for i := 0; i < 5; i++ { - backup.Attempts = append(backup.Attempts, BackupAttempt{ - ID: "attempt-" + string(rune('1'+i)), - SessionID: sessionID, - ProblemID: "problem-1", - Result: "COMPLETED", - Timestamp: now.Format(time.RFC3339), - CreatedAt: now.Format(time.RFC3339), - }) - } - - // Delete session - attempts should also be tracked as deleted - deletions := []DeletedItem{ - {ID: sessionID, Type: "session", DeletedAt: now.Format(time.RFC3339)}, - } - for _, attempt := range backup.Attempts { - deletions = append(deletions, DeletedItem{ - ID: attempt.ID, - Type: "attempt", - DeletedAt: now.Format(time.RFC3339), - }) - } - - server.applyDeletions(backup, deletions) - - if len(backup.Sessions) != 0 { - t.Errorf("Expected session to be deleted, got %d sessions", len(backup.Sessions)) - } - if len(backup.Attempts) != 0 { - t.Errorf("Expected all attempts to be deleted, got %d attempts", len(backup.Attempts)) - } - -} - -// TestFullSyncAfterReset verifies the reported user scenario -func TestFullSyncAfterReset(t *testing.T) { - tempDir := t.TempDir() - server := &SyncServer{ - dataFile: filepath.Join(tempDir, "test.json"), - imagesDir: filepath.Join(tempDir, "images"), - authToken: "test-token", - } - - now := time.Now().UTC() - - // Initial sync with data - initialData := &ClimbDataBackup{ - Version: "2.0", - FormatVersion: "2.0", - Gyms: []BackupGym{{ID: "gym-1", Name: "Test Gym", SupportedClimbTypes: []string{"BOULDER"}, DifficultySystems: []string{"V"}, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Problems: []BackupProblem{{ID: "problem-1", GymID: "gym-1", ClimbType: "BOULDER", Difficulty: DifficultyGrade{System: "V", Grade: "V5", NumericValue: 5}, IsActive: true, CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Sessions: []BackupClimbSession{{ID: "session-1", GymID: "gym-1", Date: now.Format("2006-01-02"), Status: "completed", CreatedAt: now.Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339)}}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, - } - for i := 0; i < 8; i++ { - initialData.Attempts = append(initialData.Attempts, BackupAttempt{ - ID: "attempt-" + string(rune('1'+i)), - SessionID: "session-1", - ProblemID: "problem-1", - Result: "COMPLETED", - Timestamp: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), - CreatedAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), - }) - } - server.saveData(initialData) - - // Client deletes everything and syncs - deletions := []DeletedItem{ - {ID: "gym-1", Type: "gym", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, - {ID: "problem-1", Type: "problem", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, - {ID: "session-1", Type: "session", DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, - } - for i := 0; i < 8; i++ { - deletions = append(deletions, DeletedItem{ - ID: "attempt-" + string(rune('1'+i)), - Type: "attempt", - DeletedAt: now.Add(10 * time.Minute).Format(time.RFC3339), - }) - } - - serverBackup, _ := server.loadData() - serverBackup.DeletedItems = server.mergeDeletedItems(serverBackup.DeletedItems, deletions) - server.applyDeletions(serverBackup, serverBackup.DeletedItems) - server.saveData(serverBackup) - - // Client does local reset and pulls from server - serverBackup, _ = server.loadData() - - if len(serverBackup.Gyms) != 0 { - t.Errorf("Expected 0 gyms, got %d", len(serverBackup.Gyms)) - } - if len(serverBackup.Problems) != 0 { - t.Errorf("Expected 0 problems, got %d", len(serverBackup.Problems)) - } - if len(serverBackup.Sessions) != 0 { - t.Errorf("Expected 0 sessions, got %d", len(serverBackup.Sessions)) - } - if len(serverBackup.Attempts) != 0 { - t.Errorf("Expected 0 attempts, got %d", len(serverBackup.Attempts)) - } - if len(serverBackup.DeletedItems) == 0 { - t.Errorf("Expected deletion records, got 0") - } -}