Fixed major issue with sync logic. Should be stable now. Solidified with
tests... turns out syncing is hard...
This commit is contained in:
@@ -595,66 +595,94 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
repository.resetAllData()
|
||||
|
||||
// Filter out deleted gyms before importing
|
||||
val deletedGymIds = backup.deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
|
||||
backup.gyms.forEach { backupGym ->
|
||||
try {
|
||||
val gym = backupGym.toGym()
|
||||
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
|
||||
repository.insertGymWithoutSync(gym)
|
||||
if (!deletedGymIds.contains(backupGym.id)) {
|
||||
val gym = backupGym.toGym()
|
||||
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
|
||||
repository.insertGymWithoutSync(gym)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping import of deleted gym: ${backupGym.id}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out deleted problems before importing
|
||||
val deletedProblemIds =
|
||||
backup.deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
|
||||
backup.problems.forEach { backupProblem ->
|
||||
try {
|
||||
val updatedProblem =
|
||||
if (imagePathMapping.isNotEmpty()) {
|
||||
val newImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
val filename = oldPath.substringAfterLast('/')
|
||||
if (!deletedProblemIds.contains(backupProblem.id)) {
|
||||
val updatedProblem =
|
||||
if (imagePathMapping.isNotEmpty()) {
|
||||
val newImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
val filename = oldPath.substringAfterLast('/')
|
||||
|
||||
imagePathMapping[filename]
|
||||
?: if (ImageNamingUtils.isValidImageFilename(
|
||||
filename
|
||||
)
|
||||
) {
|
||||
"problem_images/$filename"
|
||||
} else {
|
||||
val index =
|
||||
backupProblem.imagePaths.indexOf(
|
||||
oldPath
|
||||
imagePathMapping[filename]
|
||||
?: if (ImageNamingUtils.isValidImageFilename(
|
||||
filename
|
||||
)
|
||||
val consistentFilename =
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
backupProblem.id,
|
||||
index
|
||||
)
|
||||
"problem_images/$consistentFilename"
|
||||
}
|
||||
}
|
||||
?: emptyList()
|
||||
backupProblem.withUpdatedImagePaths(newImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
repository.insertProblemWithoutSync(updatedProblem.toProblem())
|
||||
) {
|
||||
"problem_images/$filename"
|
||||
} else {
|
||||
val index =
|
||||
backupProblem.imagePaths.indexOf(
|
||||
oldPath
|
||||
)
|
||||
val consistentFilename =
|
||||
ImageNamingUtils
|
||||
.generateImageFilename(
|
||||
backupProblem.id,
|
||||
index
|
||||
)
|
||||
"problem_images/$consistentFilename"
|
||||
}
|
||||
}
|
||||
?: emptyList()
|
||||
backupProblem.withUpdatedImagePaths(newImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
repository.insertProblemWithoutSync(updatedProblem.toProblem())
|
||||
} else {
|
||||
Log.d(TAG, "Skipping import of deleted problem: ${backupProblem.id}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out deleted sessions before importing
|
||||
val deletedSessionIds =
|
||||
backup.deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
|
||||
backup.sessions.forEach { backupSession ->
|
||||
try {
|
||||
repository.insertSessionWithoutSync(backupSession.toClimbSession())
|
||||
if (!deletedSessionIds.contains(backupSession.id)) {
|
||||
repository.insertSessionWithoutSync(backupSession.toClimbSession())
|
||||
} else {
|
||||
Log.d(TAG, "Skipping import of deleted session: ${backupSession.id}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out deleted attempts before importing
|
||||
val deletedAttemptIds =
|
||||
backup.deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
|
||||
backup.attempts.forEach { backupAttempt ->
|
||||
try {
|
||||
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
|
||||
if (!deletedAttemptIds.contains(backupAttempt.id)) {
|
||||
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
|
||||
} else {
|
||||
Log.d(TAG, "Skipping import of deleted attempt: ${backupAttempt.id}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}")
|
||||
}
|
||||
@@ -678,6 +706,19 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Import deletion records to prevent future resurrections
|
||||
backup.deletedItems.forEach { deletion ->
|
||||
try {
|
||||
val deletionJson = json.encodeToString(deletion)
|
||||
val preferences =
|
||||
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
|
||||
preferences.edit { putString("deleted_${deletion.id}", deletionJson) }
|
||||
Log.d(TAG, "Imported deletion record: ${deletion.type} ${deletion.id}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import deletion record: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
dataStateManager.setLastModified(backup.exportedAt)
|
||||
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
|
||||
}
|
||||
@@ -694,10 +735,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val localSessions = repository.getAllSessions().first()
|
||||
val localAttempts = repository.getAllAttempts().first()
|
||||
|
||||
// Store active sessions before clearing
|
||||
val activeSessions = localSessions.filter { it.status == SessionStatus.ACTIVE }
|
||||
// Store active sessions before clearing (but exclude any that were deleted)
|
||||
val localDeletedItems = repository.getDeletedItems()
|
||||
val allDeletedSessionIds =
|
||||
(serverBackup.deletedItems + localDeletedItems)
|
||||
.filter { it.type == "session" }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
val activeSessions =
|
||||
localSessions.filter {
|
||||
it.status == SessionStatus.ACTIVE && !allDeletedSessionIds.contains(it.id)
|
||||
}
|
||||
val activeSessionIds = activeSessions.map { it.id }.toSet()
|
||||
val activeAttempts = localAttempts.filter { activeSessionIds.contains(it.sessionId) }
|
||||
val allDeletedAttemptIds =
|
||||
(serverBackup.deletedItems + localDeletedItems)
|
||||
.filter { it.type == "attempt" }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
val activeAttempts =
|
||||
localAttempts.filter {
|
||||
activeSessionIds.contains(it.sessionId) && !allDeletedAttemptIds.contains(it.id)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Merging data...")
|
||||
val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems)
|
||||
@@ -772,9 +830,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
// 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
|
||||
try {
|
||||
val deletionJson = json.encodeToString(deletion)
|
||||
val preferences =
|
||||
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
|
||||
preferences.edit { putString("deleted_${deletion.id}", deletionJson) }
|
||||
Log.d(TAG, "Merged deletion record: ${deletion.type} ${deletion.id}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save merged deletion: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Upload merged data back to server
|
||||
|
||||
Binary file not shown.
@@ -465,11 +465,25 @@ class SyncService: ObservableObject {
|
||||
imagePathMapping: [String: String] = [:]
|
||||
) throws {
|
||||
do {
|
||||
// Store active sessions and their attempts before import
|
||||
let activeSessions = dataManager.sessions.filter { $0.status == .active }
|
||||
// Store active sessions and their attempts before import (but exclude any that were deleted)
|
||||
let localDeletedItems = dataManager.getDeletedItems()
|
||||
let allDeletedSessionIds = Set(
|
||||
(backup.deletedItems + localDeletedItems)
|
||||
.filter { $0.type == "session" }
|
||||
.map { $0.id }
|
||||
)
|
||||
let activeSessions = dataManager.sessions.filter {
|
||||
$0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString)
|
||||
}
|
||||
let activeSessionIds = Set(activeSessions.map { $0.id })
|
||||
let allDeletedAttemptIds = Set(
|
||||
(backup.deletedItems + localDeletedItems)
|
||||
.filter { $0.type == "attempt" }
|
||||
.map { $0.id }
|
||||
)
|
||||
let activeAttempts = dataManager.attempts.filter {
|
||||
activeSessionIds.contains($0.sessionId)
|
||||
&& !allDeletedAttemptIds.contains($0.id.uuidString)
|
||||
}
|
||||
|
||||
print(
|
||||
@@ -500,18 +514,58 @@ class SyncService: ObservableObject {
|
||||
updatedAt: problem.updatedAt
|
||||
)
|
||||
}
|
||||
// Filter out deleted items before creating updated backup
|
||||
let deletedGymIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
|
||||
let filteredProblems = updatedProblems.filter { !deletedProblemIds.contains($0.id) }
|
||||
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
|
||||
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
|
||||
|
||||
updatedBackup = ClimbDataBackup(
|
||||
exportedAt: backup.exportedAt,
|
||||
version: backup.version,
|
||||
formatVersion: backup.formatVersion,
|
||||
gyms: backup.gyms,
|
||||
problems: updatedProblems,
|
||||
sessions: backup.sessions,
|
||||
attempts: backup.attempts
|
||||
gyms: filteredGyms,
|
||||
problems: filteredProblems,
|
||||
sessions: filteredSessions,
|
||||
attempts: filteredAttempts,
|
||||
deletedItems: backup.deletedItems
|
||||
)
|
||||
|
||||
} else {
|
||||
updatedBackup = backup
|
||||
// Filter out deleted items even when no image path mapping
|
||||
let deletedGymIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(
|
||||
backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) }
|
||||
let filteredProblems = backup.problems.filter { !deletedProblemIds.contains($0.id) }
|
||||
let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) }
|
||||
let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) }
|
||||
|
||||
updatedBackup = ClimbDataBackup(
|
||||
exportedAt: backup.exportedAt,
|
||||
version: backup.version,
|
||||
formatVersion: backup.formatVersion,
|
||||
gyms: filteredGyms,
|
||||
problems: filteredProblems,
|
||||
sessions: filteredSessions,
|
||||
attempts: filteredAttempts,
|
||||
deletedItems: backup.deletedItems
|
||||
)
|
||||
}
|
||||
|
||||
// Create a minimal ZIP with just the JSON data for existing import mechanism
|
||||
@@ -538,6 +592,13 @@ class SyncService: ObservableObject {
|
||||
dataManager.saveAttempts()
|
||||
dataManager.saveActiveSession()
|
||||
|
||||
// Import deletion records to prevent future resurrections
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(backup.deletedItems) {
|
||||
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
|
||||
print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records")
|
||||
}
|
||||
|
||||
// Update local data state to match imported data timestamp
|
||||
DataStateManager.shared.setLastModified(backup.exportedAt)
|
||||
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
|
||||
|
||||
@@ -452,8 +452,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Update Live Activity when attempt is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Note: Attempts for active sessions are not synced until session is completed
|
||||
}
|
||||
|
||||
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
||||
|
||||
Reference in New Issue
Block a user