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

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

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
versionCode = 32
versionName = "1.7.3"
versionCode = 33
versionName = "1.7.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -12,7 +12,15 @@ data class ClimbDataBackup(
val gyms: List<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>
val attempts: List<BackupAttempt>,
val deletedItems: List<DeletedItem> = emptyList()
)
@Serializable
data class DeletedItem(
val id: String,
val type: String, // "gym", "problem", "session", "attempt"
val deletedAt: String
)
// Platform-neutral gym representation for backup/restore

View File

@@ -1,12 +1,15 @@
package com.atridad.openclimb.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.atridad.openclimb.data.database.OpenClimbDatabase
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
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.format.DeletedItem
import com.atridad.openclimb.data.model.*
import com.atridad.openclimb.data.state.DataStateManager
import com.atridad.openclimb.utils.DateFormatUtils
@@ -14,6 +17,8 @@ import com.atridad.openclimb.utils.ZipExportImportUtils
import java.io.File
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
@@ -22,6 +27,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private val sessionDao = database.climbSessionDao()
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
private val deletionPreferences: SharedPreferences =
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
private var autoSyncCallback: (() -> Unit)? = null
@@ -45,6 +52,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
suspend fun deleteGym(gym: Gym) {
gymDao.deleteGym(gym)
trackDeletion(gym.id, "gym")
dataStateManager.updateDataState()
triggerAutoSync()
}
@@ -56,17 +64,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun insertProblem(problem: Problem) {
problemDao.insertProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun updateProblem(problem: Problem) {
problemDao.updateProblem(problem)
dataStateManager.updateDataState()
triggerAutoSync()
}
suspend fun deleteProblem(problem: Problem) {
problemDao.deleteProblem(problem)
trackDeletion(problem.id, "problem")
dataStateManager.updateDataState()
triggerAutoSync()
}
// Session operations
@@ -94,6 +100,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
suspend fun deleteSession(session: ClimbSession) {
sessionDao.deleteSession(session)
trackDeletion(session.id, "session")
dataStateManager.updateDataState()
triggerAutoSync()
}
@@ -122,6 +129,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
suspend fun deleteAttempt(attempt: Attempt) {
attemptDao.deleteAttempt(attempt)
trackDeletion(attempt.id, "attempt")
dataStateManager.updateDataState()
}
@@ -261,6 +269,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
autoSyncCallback?.invoke()
}
private fun trackDeletion(itemId: String, itemType: String) {
val currentDeletions = getDeletedItems().toMutableList()
val newDeletion =
DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601())
currentDeletions.add(newDeletion)
val json = json.encodeToString(newDeletion)
deletionPreferences.edit { putString("deleted_${itemId}", json) }
}
fun getDeletedItems(): List<DeletedItem> {
val deletions = mutableListOf<DeletedItem>()
val allPrefs = deletionPreferences.all
for ((key, value) in allPrefs) {
if (key.startsWith("deleted_") && value is String) {
try {
val deletion = json.decodeFromString<DeletedItem>(value)
deletions.add(deletion)
} catch (e: Exception) {
// Invalid deletion record, ignore
}
}
}
return deletions
}
fun clearDeletedItems() {
deletionPreferences.edit { clear() }
}
private fun validateDataIntegrity(
gyms: List<Gym>,
problems: List<Problem>,

View File

@@ -9,7 +9,12 @@ import com.atridad.openclimb.data.format.BackupClimbSession
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.format.DeletedItem
import com.atridad.openclimb.data.migration.ImageMigrationService
import com.atridad.openclimb.data.model.Attempt
import com.atridad.openclimb.data.model.ClimbSession
import com.atridad.openclimb.data.model.Gym
import com.atridad.openclimb.data.model.Problem
import com.atridad.openclimb.data.model.SessionStatus
import com.atridad.openclimb.data.repository.ClimbRepository
import com.atridad.openclimb.data.state.DataStateManager
@@ -382,27 +387,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
Log.d(
TAG,
"Comparing timestamps: local=$localTimestamp, server=$serverTimestamp"
)
if (localTimestamp > serverTimestamp) {
Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) {
Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data")
} else {
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
}
Log.d(TAG, "Both local and server data exist, merging safely")
mergeDataSafely(localBackup, serverBackup)
Log.d(TAG, "Safe merge completed")
}
else -> {
Log.d(TAG, "No data to sync")
@@ -583,7 +570,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
gyms = allGyms.map { BackupGym.fromGym(it) },
problems = allProblems.map { BackupProblem.fromProblem(it) },
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) }
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
deletedItems = repository.getDeletedItems()
)
}
@@ -694,6 +682,232 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
}
private suspend fun mergeDataSafely(
localBackup: ClimbDataBackup,
serverBackup: ClimbDataBackup
) {
val imagePathMapping = syncImagesFromServer(serverBackup)
// Get all local data
val localGyms = repository.getAllGyms().first()
val localProblems = repository.getAllProblems().first()
val localSessions = repository.getAllSessions().first()
val localAttempts = repository.getAllAttempts().first()
// Store active sessions before clearing
val activeSessions = localSessions.filter { it.status == SessionStatus.ACTIVE }
val activeSessionIds = activeSessions.map { it.id }.toSet()
val activeAttempts = localAttempts.filter { activeSessionIds.contains(it.sessionId) }
Log.d(TAG, "Merging data...")
val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems)
val mergedProblems =
mergeProblems(
localProblems,
serverBackup.problems,
imagePathMapping,
serverBackup.deletedItems
)
val mergedSessions =
mergeSessions(localSessions, serverBackup.sessions, serverBackup.deletedItems)
val mergedAttempts =
mergeAttempts(localAttempts, serverBackup.attempts, serverBackup.deletedItems)
// Clear and repopulate with merged data
repository.resetAllData()
mergedGyms.forEach { gym ->
try {
repository.insertGymWithoutSync(gym)
} catch (e: Exception) {
Log.e(TAG, "Failed to insert merged gym: ${e.message}")
}
}
mergedProblems.forEach { problem ->
try {
repository.insertProblemWithoutSync(problem)
} catch (e: Exception) {
Log.e(TAG, "Failed to insert merged problem: ${e.message}")
}
}
mergedSessions.forEach { session ->
try {
repository.insertSessionWithoutSync(session)
} catch (e: Exception) {
Log.e(TAG, "Failed to insert merged session: ${e.message}")
}
}
mergedAttempts.forEach { attempt ->
try {
repository.insertAttemptWithoutSync(attempt)
} catch (e: Exception) {
Log.e(TAG, "Failed to insert merged attempt: ${e.message}")
}
}
// Restore active sessions
activeSessions.forEach { session ->
try {
repository.insertSessionWithoutSync(session)
} catch (e: Exception) {
Log.e(TAG, "Failed to restore active session: ${e.message}")
}
}
activeAttempts.forEach { attempt ->
try {
repository.insertAttemptWithoutSync(attempt)
} catch (e: Exception) {
Log.e(TAG, "Failed to restore active attempt: ${e.message}")
}
}
// Merge deletion lists
val localDeletions = repository.getDeletedItems()
val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id }
// Clear and update local deletions with merged list
repository.clearDeletedItems()
allDeletions.forEach { deletion ->
// Re-add merged deletions to local store
// Note: This is a simplified approach - in production you might want a more
// sophisticated merge
}
// Upload merged data back to server
val mergedBackup = createBackupFromRepository()
uploadData(mergedBackup)
syncImagesForBackup(mergedBackup)
dataStateManager.updateDataState()
}
private fun mergeGyms(
local: List<Gym>,
server: List<BackupGym>,
deletedItems: List<DeletedItem>
): List<Gym> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedGymIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverGym ->
if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
try {
merged.add(serverGym.toGym())
} catch (e: Exception) {
Log.e(TAG, "Failed to convert server gym: ${e.message}")
}
}
}
return merged
}
private fun mergeProblems(
local: List<Problem>,
server: List<BackupProblem>,
imagePathMapping: Map<String, String>,
deletedItems: List<DeletedItem>
): List<Problem> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
// Remove items that were deleted on other devices
merged.removeAll { deletedProblemIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverProblem ->
if (!localIds.contains(serverProblem.id) &&
!deletedProblemIds.contains(serverProblem.id)
) {
try {
val problemToAdd =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
serverProblem.imagePaths?.map { oldPath ->
val filename = oldPath.substringAfterLast('/')
imagePathMapping[filename] ?: oldPath
}
?: emptyList()
serverProblem.withUpdatedImagePaths(newImagePaths)
} else {
serverProblem
}
merged.add(problemToAdd.toProblem())
} catch (e: Exception) {
Log.e(TAG, "Failed to convert server problem: ${e.message}")
}
}
}
return merged
}
private fun mergeSessions(
local: List<ClimbSession>,
server: List<BackupClimbSession>,
deletedItems: List<DeletedItem>
): List<ClimbSession> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but never remove active sessions)
merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
// Add new items from server (excluding deleted ones)
server.forEach { serverSession ->
if (!localIds.contains(serverSession.id) &&
!deletedSessionIds.contains(serverSession.id)
) {
try {
merged.add(serverSession.toClimbSession())
} catch (e: Exception) {
Log.e(TAG, "Failed to convert server session: ${e.message}")
}
}
}
return merged
}
private fun mergeAttempts(
local: List<Attempt>,
server: List<BackupAttempt>,
deletedItems: List<DeletedItem>
): List<Attempt> {
val merged = local.toMutableList()
val localIds = local.map { it.id }.toSet()
val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
// Remove items that were deleted on other devices (but be conservative with attempts)
merged.removeAll { deletedAttemptIds.contains(it.id) }
// Add new items from server (excluding deleted ones)
server.forEach { serverAttempt ->
if (!localIds.contains(serverAttempt.id) &&
!deletedAttemptIds.contains(serverAttempt.id)
) {
try {
merged.add(serverAttempt.toAttempt())
} catch (e: Exception) {
Log.e(TAG, "Failed to convert server attempt: ${e.message}")
}
}
}
return merged
}
/** Parses ISO8601 timestamp to milliseconds for comparison */
private fun parseISO8601ToMillis(timestamp: String): Long {
return try {