From 6e490d15986887babf70aabacb94bd6fef993be3 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Sun, 28 Sep 2025 23:12:46 -0600 Subject: [PATCH] Sync Server DONE! --- ...otlin-compiler-12230421336915548227.salive | 0 android/app/build.gradle.kts | 2 + android/app/build.gradle.kts.backup | 98 ++ android/app/build_new.gradle.kts | 98 ++ android/app/src/main/AndroidManifest.xml | 3 + .../openclimb/data/format/BackupFormat.kt | 9 +- .../data/migration/ImageMigrationService.kt | 205 ++++ .../atridad/openclimb/data/model/Attempt.kt | 96 +- .../openclimb/data/model/ClimbSession.kt | 101 +- .../openclimb/data/model/DifficultySystem.kt | 244 +++-- .../com/atridad/openclimb/data/model/Gym.kt | 53 +- .../atridad/openclimb/data/model/Problem.kt | 4 +- .../data/repository/ClimbRepository.kt | 137 ++- .../openclimb/data/state/DataStateManager.kt | 81 ++ .../openclimb/data/sync/SyncService.kt | 998 ++++++++++++++++++ .../com/atridad/openclimb/ui/OpenClimbApp.kt | 9 +- .../openclimb/ui/screens/SettingsScreen.kt | 961 ++++++++++++----- .../openclimb/ui/viewmodel/ClimbViewModel.kt | 25 +- .../ui/viewmodel/ClimbViewModelFactory.kt | 8 +- .../openclimb/utils/DateFormatUtils.kt | 68 ++ .../openclimb/utils/ImageNamingUtils.kt | 147 +++ .../com/atridad/openclimb/utils/ImageUtils.kt | 254 ++++- .../atridad/openclimb/SyncMergeLogicTest.kt | 451 ++++++++ android/gradle/libs.versions.toml | 4 + android/gradle/wrapper/gradle-wrapper.jar | 16 + .../UserInterfaceState.xcuserstate | Bin 117982 -> 124304 bytes .../xcshareddata/xcschemes/OpenClimb.xcscheme | 78 ++ ios/OpenClimb/ContentView.swift | 4 + ios/OpenClimb/Models/BackupFormat.swift | 5 - ios/OpenClimb/Services/SyncService.swift | 978 +++++++++++++++++ ios/OpenClimb/Utils/DataStateManager.swift | 85 ++ ios/OpenClimb/Utils/ImageNamingUtils.swift | 176 +++ .../ViewModels/ClimbingDataManager.swift | 29 + ios/OpenClimb/Views/SettingsView.swift | 358 +++++++ sync-server/.env.example | 14 + sync-server/.gitignore | 16 + sync-server/Dockerfile | 14 + sync-server/docker-compose.yml | 14 + sync-server/go.mod | 3 + sync-server/main.go | 358 +++++++ sync-server/run.sh | 31 + 41 files changed, 5684 insertions(+), 551 deletions(-) delete mode 100644 android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive create mode 100644 android/app/build.gradle.kts.backup create mode 100644 android/app/build_new.gradle.kts create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt create mode 100644 android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt create mode 100644 android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 ios/OpenClimb/Services/SyncService.swift create mode 100644 ios/OpenClimb/Utils/DataStateManager.swift create mode 100644 ios/OpenClimb/Utils/ImageNamingUtils.swift create mode 100644 sync-server/.env.example create mode 100644 sync-server/.gitignore create mode 100644 sync-server/Dockerfile create mode 100644 sync-server/docker-compose.yml create mode 100644 sync-server/go.mod create mode 100644 sync-server/main.go create mode 100755 sync-server/run.sh diff --git a/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive b/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dc0c78f..2349fa4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -92,4 +92,6 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/android/app/build.gradle.kts.backup b/android/app/build.gradle.kts.backup new file mode 100644 index 0000000..3d33435 --- /dev/null +++ b/android/app/build.gradle.kts.backup @@ -0,0 +1,98 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.atridad.openclimb" + compileSdk = 36 + + defaultConfig { + applicationId = "com.atridad.openclimb" + minSdk = 31 + targetSdk = 36 + versionCode = 27 + versionName = "1.6.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } + + buildFeatures { compose = true } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } + +dependencies { + // Core Android libraries + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM and UI + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/android/app/build_new.gradle.kts b/android/app/build_new.gradle.kts new file mode 100644 index 0000000..5fec1e4 --- /dev/null +++ b/android/app/build_new.gradle.kts @@ -0,0 +1,98 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.atridad.openclimb" + compileSdk = 36 + + defaultConfig { + applicationId = "com.atridad.openclimb" + minSdk = 31 + targetSdk = 36 + versionCode = 27 + versionName = "1.6.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } + + buildFeatures { compose = true } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } + +dependencies { + // Core Android libraries + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM and UI + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + + // Room Database + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + + ksp(libs.androidx.room.compiler) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // ViewModel + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Image Loading + implementation(libs.coil.compose) + + // HTTP Client + implementation(libs.okhttp) + + // Testing + testImplementation(libs.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5af5fc4..c187fce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ android:maxSdkVersion="28" /> + + + diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt index e1658a7..f8fae25 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -23,6 +23,7 @@ data class BackupGym( val location: String? = null, val supportedClimbTypes: List, val difficultySystems: List, + @kotlinx.serialization.SerialName("customDifficultyGrades") val customDifficultyGrades: List = emptyList(), val notes: String? = null, val createdAt: String, // ISO 8601 format @@ -91,7 +92,13 @@ data class BackupProblem( difficulty = problem.difficulty, tags = problem.tags, location = problem.location, - imagePaths = problem.imagePaths.ifEmpty { null }, + imagePaths = + if (problem.imagePaths.isEmpty()) null + else + problem.imagePaths.map { path -> + // Store just the filename to match iOS format + path.substringAfterLast('/') + }, isActive = problem.isActive, dateSet = problem.dateSet, notes = problem.notes, diff --git a/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt new file mode 100644 index 0000000..0ec8afc --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/migration/ImageMigrationService.kt @@ -0,0 +1,205 @@ +package com.atridad.openclimb.data.migration + +import android.content.Context +import android.util.Log +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.utils.ImageNamingUtils +import com.atridad.openclimb.utils.ImageUtils +import kotlinx.coroutines.flow.first + +/** + * Service responsible for migrating images to use consistent naming convention across platforms. + * This ensures that iOS and Android use the same image filenames for sync compatibility. + */ +class ImageMigrationService(private val context: Context, private val repository: ClimbRepository) { + companion object { + private const val TAG = "ImageMigrationService" + private const val MIGRATION_PREF_KEY = "image_naming_migration_completed" + } + + /** + * Performs a complete migration of all images in the system to use consistent naming. This + * should be called once during app startup after the naming convention is implemented. + */ + suspend fun performFullMigration(): ImageMigrationResult { + Log.i(TAG, "Starting full image naming migration") + + val prefs = context.getSharedPreferences("openclimb_migration", Context.MODE_PRIVATE) + if (prefs.getBoolean(MIGRATION_PREF_KEY, false)) { + Log.i(TAG, "Image migration already completed, skipping") + return ImageMigrationResult.AlreadyCompleted + } + + try { + val allProblems = repository.getAllProblems().first() + val migrationResults = mutableMapOf() + var migratedCount = 0 + var errorCount = 0 + + Log.i(TAG, "Found ${allProblems.size} problems to check for image migration") + + for (problem in allProblems) { + if (problem.imagePaths.isNotEmpty()) { + Log.d( + TAG, + "Migrating images for problem '${problem.name}': ${problem.imagePaths}" + ) + + try { + val problemMigrations = + ImageUtils.migrateImageNaming( + context = context, + problemId = problem.id, + currentImagePaths = problem.imagePaths + ) + + if (problemMigrations.isNotEmpty()) { + migrationResults.putAll(problemMigrations) + migratedCount += problemMigrations.size + + // Update problem with new image paths + val newImagePaths = + problem.imagePaths.map { oldPath -> + problemMigrations[oldPath] ?: oldPath + } + + val updatedProblem = problem.copy(imagePaths = newImagePaths) + repository.insertProblem(updatedProblem) + + Log.d( + TAG, + "Updated problem '${problem.name}' with ${problemMigrations.size} migrated images" + ) + } + } catch (e: Exception) { + Log.e( + TAG, + "Failed to migrate images for problem '${problem.name}': ${e.message}", + e + ) + errorCount++ + } + } + } + + // Mark migration as completed + prefs.edit().putBoolean(MIGRATION_PREF_KEY, true).apply() + + Log.i( + TAG, + "Image migration completed: $migratedCount images migrated, $errorCount errors" + ) + + return ImageMigrationResult.Success( + totalMigrated = migratedCount, + errors = errorCount, + migrations = migrationResults + ) + } catch (e: Exception) { + Log.e(TAG, "Image migration failed: ${e.message}", e) + return ImageMigrationResult.Failed(e.message ?: "Unknown error") + } + } + + /** Validates that all images in the system follow the consistent naming convention. */ + suspend fun validateImageNaming(): ValidationResult { + try { + val allProblems = repository.getAllProblems().first() + val validImages = mutableListOf() + val invalidImages = mutableListOf() + val missingImages = mutableListOf() + + for (problem in allProblems) { + for (imagePath in problem.imagePaths) { + val filename = imagePath.substringAfterLast('/') + + // Check if file exists + val imageFile = ImageUtils.getImageFile(context, imagePath) + if (!imageFile.exists()) { + missingImages.add(imagePath) + continue + } + + // Check if filename follows our convention + if (ImageNamingUtils.isValidImageFilename(filename)) { + validImages.add(imagePath) + } else { + invalidImages.add(imagePath) + } + } + } + + return ValidationResult( + totalImages = validImages.size + invalidImages.size + missingImages.size, + validImages = validImages, + invalidImages = invalidImages, + missingImages = missingImages + ) + } catch (e: Exception) { + Log.e(TAG, "Image validation failed: ${e.message}", e) + return ValidationResult( + totalImages = 0, + validImages = emptyList(), + invalidImages = emptyList(), + missingImages = emptyList() + ) + } + } + + /** Migrates images for a specific problem during sync operations. */ + suspend fun migrateProblemImages( + problemId: String, + currentImagePaths: List + ): Map { + return try { + ImageUtils.migrateImageNaming(context, problemId, currentImagePaths) + } catch (e: Exception) { + Log.e(TAG, "Failed to migrate images for problem $problemId: ${e.message}", e) + emptyMap() + } + } + + /** + * Cleans up any orphaned image files that don't follow our naming convention and aren't + * referenced by any problems. + */ + suspend fun cleanupOrphanedImages() { + try { + val allProblems = repository.getAllProblems().first() + val referencedPaths = allProblems.flatMap { it.imagePaths }.toSet() + + ImageUtils.cleanupOrphanedImages(context, referencedPaths) + + Log.i(TAG, "Orphaned image cleanup completed") + } catch (e: Exception) { + Log.e(TAG, "Failed to cleanup orphaned images: ${e.message}", e) + } + } +} + +/** Result of an image migration operation */ +sealed class ImageMigrationResult { + object AlreadyCompleted : ImageMigrationResult() + + data class Success( + val totalMigrated: Int, + val errors: Int, + val migrations: Map + ) : ImageMigrationResult() + + data class Failed(val error: String) : ImageMigrationResult() +} + +/** Result of image naming validation */ +data class ValidationResult( + val totalImages: Int, + val validImages: List, + val invalidImages: List, + val missingImages: List +) { + val isAllValid: Boolean + get() = invalidImages.isEmpty() && missingImages.isEmpty() + + val validPercentage: Double + get() = if (totalImages == 0) 100.0 else (validImages.size.toDouble() / totalImages) * 100 +} diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt index 1f93a1c..794cf7d 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Attempt.kt @@ -4,8 +4,8 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable enum class AttemptResult { @@ -16,63 +16,59 @@ enum class AttemptResult { } @Entity( - tableName = "attempts", - foreignKeys = [ - ForeignKey( - entity = ClimbSession::class, - parentColumns = ["id"], - childColumns = ["sessionId"], - onDelete = ForeignKey.CASCADE - ), - ForeignKey( - entity = Problem::class, - parentColumns = ["id"], - childColumns = ["problemId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [ - Index(value = ["sessionId"]), - Index(value = ["problemId"]) - ] + tableName = "attempts", + foreignKeys = + [ + ForeignKey( + entity = ClimbSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Problem::class, + parentColumns = ["id"], + childColumns = ["problemId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["sessionId"]), Index(value = ["problemId"])] ) @Serializable data class Attempt( - @PrimaryKey - val id: String, - val sessionId: String, - val problemId: String, - val result: AttemptResult, - val highestHold: String? = null, // Description of the highest hold reached - val notes: String? = null, - val duration: Long? = null, // Attempt duration in seconds - val restTime: Long? = null, // Rest time before this attempt in seconds - val timestamp: String, // When this attempt was made - val createdAt: String + @PrimaryKey val id: String, + val sessionId: String, + val problemId: String, + val result: AttemptResult, + val highestHold: String? = null, // Description of the highest hold reached + val notes: String? = null, + val duration: Long? = null, // Attempt duration in seconds + val restTime: Long? = null, // Rest time before this attempt in seconds + val timestamp: String, // When this attempt was made + val createdAt: String ) { companion object { fun create( - sessionId: String, - problemId: String, - result: AttemptResult, - highestHold: String? = null, - notes: String? = null, - duration: Long? = null, - restTime: Long? = null, - timestamp: String = LocalDateTime.now().toString() + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String? = null, + notes: String? = null, + duration: Long? = null, + restTime: Long? = null, + timestamp: String = DateFormatUtils.nowISO8601() ): Attempt { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Attempt( - id = java.util.UUID.randomUUID().toString(), - sessionId = sessionId, - problemId = problemId, - result = result, - highestHold = highestHold, - notes = notes, - duration = duration, - restTime = restTime, - timestamp = timestamp, - createdAt = now + id = java.util.UUID.randomUUID().toString(), + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = now ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt index f3b9c7d..0c4b5c9 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/ClimbSession.kt @@ -4,8 +4,8 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Serializable enum class SessionStatus { @@ -15,66 +15,65 @@ enum class SessionStatus { } @Entity( - tableName = "climb_sessions", - foreignKeys = [ - ForeignKey( - entity = Gym::class, - parentColumns = ["id"], - childColumns = ["gymId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index(value = ["gymId"])] + tableName = "climb_sessions", + foreignKeys = + [ + ForeignKey( + entity = Gym::class, + parentColumns = ["id"], + childColumns = ["gymId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["gymId"])] ) @Serializable data class ClimbSession( - @PrimaryKey - val id: String, - val gymId: String, - val date: String, - val startTime: String? = null, - val endTime: String? = null, - val duration: Long? = null, - val status: SessionStatus = SessionStatus.ACTIVE, - val notes: String? = null, - val createdAt: String, - val updatedAt: String + @PrimaryKey val id: String, + val gymId: String, + val date: String, + val startTime: String? = null, + val endTime: String? = null, + val duration: Long? = null, + val status: SessionStatus = SessionStatus.ACTIVE, + val notes: String? = null, + val createdAt: String, + val updatedAt: String ) { companion object { - fun create( - gymId: String, - notes: String? = null - ): ClimbSession { - val now = LocalDateTime.now().toString() + fun create(gymId: String, notes: String? = null): ClimbSession { + val now = DateFormatUtils.nowISO8601() return ClimbSession( - id = java.util.UUID.randomUUID().toString(), - gymId = gymId, - date = now, - startTime = now, - status = SessionStatus.ACTIVE, - notes = notes, - createdAt = now, - updatedAt = now + id = java.util.UUID.randomUUID().toString(), + gymId = gymId, + date = now, + startTime = now, + status = SessionStatus.ACTIVE, + notes = notes, + createdAt = now, + updatedAt = now ) } - + fun ClimbSession.complete(): ClimbSession { - val endTime = LocalDateTime.now().toString() - val durationMinutes = if (startTime != null) { - try { - val start = LocalDateTime.parse(startTime) - val end = LocalDateTime.parse(endTime) - java.time.Duration.between(start, end).toMinutes() - } catch (_: Exception) { - null - } - } else null - + val endTime = DateFormatUtils.nowISO8601() + val durationMinutes = + if (startTime != null) { + try { + val start = DateFormatUtils.parseISO8601(startTime) + val end = DateFormatUtils.parseISO8601(endTime) + if (start != null && end != null) { + java.time.Duration.between(start, end).toMinutes() + } else null + } catch (_: Exception) { + null + } + } else null + return this.copy( - endTime = endTime, - duration = durationMinutes, - status = SessionStatus.COMPLETED, - updatedAt = LocalDateTime.now().toString() + endTime = endTime, + duration = durationMinutes, + status = SessionStatus.COMPLETED, + updatedAt = DateFormatUtils.nowISO8601() ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt index 835e600..fd58bb9 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/DifficultySystem.kt @@ -7,75 +7,199 @@ enum class DifficultySystem { // Bouldering V_SCALE, // V-Scale (VB - V17) FONT, // Fontainebleau (3 - 8C+) - + // Rope YDS, // Yosemite Decimal System (5.0 - 5.15d) - + // Custom difficulty systems CUSTOM; - - /** - * Get the display name for the UI - */ - fun getDisplayName(): String = when (this) { - V_SCALE -> "V Scale" - FONT -> "Font Scale" - YDS -> "YDS (Yosemite)" - CUSTOM -> "Custom" - } - - /** - * Check if this system is for bouldering - */ - fun isBoulderingSystem(): Boolean = when (this) { - V_SCALE, FONT -> true - YDS -> false - CUSTOM -> true // Custom is available for all - } - - /** - * Check if this system is for rope climbing - */ - fun isRopeSystem(): Boolean = when (this) { - YDS -> true - V_SCALE, FONT -> false - CUSTOM -> true - } - - /** - * Get available grades for this system - */ - fun getAvailableGrades(): List = when (this) { - V_SCALE -> listOf("VB", "V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9", "V10", "V11", "V12", "V13", "V14", "V15", "V16", "V17") - FONT -> listOf("3", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6A+", "6B", "6B+", "6C", "6C+", "7A", "7A+", "7B", "7B+", "7C", "7C+", "8A", "8A+", "8B", "8B+", "8C", "8C+") - YDS -> listOf("5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "5.9", "5.10a", "5.10b", "5.10c", "5.10d", "5.11a", "5.11b", "5.11c", "5.11d", "5.12a", "5.12b", "5.12c", "5.12d", "5.13a", "5.13b", "5.13c", "5.13d", "5.14a", "5.14b", "5.14c", "5.14d", "5.15a", "5.15b", "5.15c", "5.15d") - CUSTOM -> emptyList() - } - + + /** Get the display name for the UI */ + fun getDisplayName(): String = + when (this) { + V_SCALE -> "V Scale" + FONT -> "Font Scale" + YDS -> "YDS (Yosemite)" + CUSTOM -> "Custom" + } + + /** Check if this system is for bouldering */ + fun isBoulderingSystem(): Boolean = + when (this) { + V_SCALE, FONT -> true + YDS -> false + CUSTOM -> true // Custom is available for all + } + + /** Check if this system is for rope climbing */ + fun isRopeSystem(): Boolean = + when (this) { + YDS -> true + V_SCALE, FONT -> false + CUSTOM -> true + } + + /** Get available grades for this system */ + fun getAvailableGrades(): List = + when (this) { + V_SCALE -> + listOf( + "VB", + "V0", + "V1", + "V2", + "V3", + "V4", + "V5", + "V6", + "V7", + "V8", + "V9", + "V10", + "V11", + "V12", + "V13", + "V14", + "V15", + "V16", + "V17" + ) + FONT -> + listOf( + "3", + "4A", + "4B", + "4C", + "5A", + "5B", + "5C", + "6A", + "6A+", + "6B", + "6B+", + "6C", + "6C+", + "7A", + "7A+", + "7B", + "7B+", + "7C", + "7C+", + "8A", + "8A+", + "8B", + "8B+", + "8C", + "8C+" + ) + YDS -> + listOf( + "5.0", + "5.1", + "5.2", + "5.3", + "5.4", + "5.5", + "5.6", + "5.7", + "5.8", + "5.9", + "5.10a", + "5.10b", + "5.10c", + "5.10d", + "5.11a", + "5.11b", + "5.11c", + "5.11d", + "5.12a", + "5.12b", + "5.12c", + "5.12d", + "5.13a", + "5.13b", + "5.13c", + "5.13d", + "5.14a", + "5.14b", + "5.14c", + "5.14d", + "5.15a", + "5.15b", + "5.15c", + "5.15d" + ) + CUSTOM -> emptyList() + } + companion object { - /** - * Get all difficulty systems based on type - */ - fun getSystemsForClimbType(climbType: ClimbType): List = when (climbType) { - ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } - ClimbType.ROPE -> entries.filter { it.isRopeSystem() } - } + /** Get all difficulty systems based on type */ + fun getSystemsForClimbType(climbType: ClimbType): List = + when (climbType) { + ClimbType.BOULDER -> entries.filter { it.isBoulderingSystem() } + ClimbType.ROPE -> entries.filter { it.isRopeSystem() } + } } } @Serializable -data class DifficultyGrade( - val system: DifficultySystem, - val grade: String, - val numericValue: Int -) { +data class DifficultyGrade(val system: DifficultySystem, val grade: String, val numericValue: Int) { + + constructor( + system: DifficultySystem, + grade: String + ) : this(system = system, grade = grade, numericValue = calculateNumericValue(system, grade)) + + companion object { + private fun calculateNumericValue(system: DifficultySystem, grade: String): Int { + return when (system) { + DifficultySystem.V_SCALE -> { + if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0 + } + DifficultySystem.YDS -> { + // Simplified numeric mapping for YDS grades + when { + grade.startsWith("5.10") -> + 10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.11") -> + 14 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.12") -> + 18 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.13") -> + 22 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.14") -> + 26 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + grade.startsWith("5.15") -> + 30 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0) + else -> grade.removePrefix("5.").toIntOrNull() ?: 0 + } + } + DifficultySystem.FONT -> { + // Simplified Font grade mapping + when { + grade.startsWith("6A") -> 6 + grade.startsWith("6B") -> 7 + grade.startsWith("6C") -> 8 + grade.startsWith("7A") -> 9 + grade.startsWith("7B") -> 10 + grade.startsWith("7C") -> 11 + grade.startsWith("8A") -> 12 + grade.startsWith("8B") -> 13 + grade.startsWith("8C") -> 14 + else -> grade.toIntOrNull() ?: 0 + } + } + DifficultySystem.CUSTOM -> grade.hashCode().rem(100) + } + } + } /** - * Compare this grade with another grade of the same system - * Returns negative if this grade is easier, positive if harder, 0 if equal + * Compare this grade with another grade of the same system Returns negative if this grade is + * easier, positive if harder, 0 if equal */ fun compareTo(other: DifficultyGrade): Int { if (system != other.system) return 0 - + return when (system) { DifficultySystem.V_SCALE -> compareVScaleGrades(grade, other.grade) DifficultySystem.FONT -> compareFontGrades(grade, other.grade) @@ -83,24 +207,24 @@ data class DifficultyGrade( DifficultySystem.CUSTOM -> grade.compareTo(other.grade) } } - + private fun compareVScaleGrades(grade1: String, grade2: String): Int { // Handle VB (easiest) specially if (grade1 == "VB" && grade2 != "VB") return -1 if (grade2 == "VB" && grade1 != "VB") return 1 if (grade1 == "VB" && grade2 == "VB") return 0 - + // Extract numeric values for V grades val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0 val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0 return num1.compareTo(num2) } - + private fun compareFontGrades(grade1: String, grade2: String): Int { // Simple string comparison for Font grades return grade1.compareTo(grade2) } - + private fun compareYDSGrades(grade1: String, grade2: String): Int { // Simple string comparison for YDS grades return grade1.compareTo(grade2) diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt index de81001..1876cd9 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Gym.kt @@ -2,43 +2,42 @@ package com.atridad.openclimb.data.model import androidx.room.Entity import androidx.room.PrimaryKey +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable -import java.time.LocalDateTime @Entity(tableName = "gyms") @Serializable data class Gym( - @PrimaryKey - val id: String, - val name: String, - val location: String? = null, - val supportedClimbTypes: List, - val difficultySystems: List, - val customDifficultyGrades: List = emptyList(), - val notes: String? = null, - val createdAt: String, - val updatedAt: String + @PrimaryKey val id: String, + val name: String, + val location: String? = null, + val supportedClimbTypes: List, + val difficultySystems: List, + val customDifficultyGrades: List = emptyList(), + val notes: String? = null, + val createdAt: String, + val updatedAt: String ) { companion object { fun create( - name: String, - location: String? = null, - supportedClimbTypes: List, - difficultySystems: List, - customDifficultyGrades: List = emptyList(), - notes: String? = null + name: String, + location: String? = null, + supportedClimbTypes: List, + difficultySystems: List, + customDifficultyGrades: List = emptyList(), + notes: String? = null ): Gym { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Gym( - id = java.util.UUID.randomUUID().toString(), - name = name, - location = location, - supportedClimbTypes = supportedClimbTypes, - difficultySystems = difficultySystems, - customDifficultyGrades = customDifficultyGrades, - notes = notes, - createdAt = now, - updatedAt = now + id = java.util.UUID.randomUUID().toString(), + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = now, + updatedAt = now ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt index 1116f7f..b983d22 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/model/Problem.kt @@ -4,7 +4,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import java.time.LocalDateTime +import com.atridad.openclimb.utils.DateFormatUtils import kotlinx.serialization.Serializable @Entity( @@ -49,7 +49,7 @@ data class Problem( dateSet: String? = null, notes: String? = null ): Problem { - val now = LocalDateTime.now().toString() + val now = DateFormatUtils.nowISO8601() return Problem( id = java.util.UUID.randomUUID().toString(), gymId = gymId, diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 37acff7..01aced1 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -8,9 +8,10 @@ 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.model.* +import com.atridad.openclimb.data.state.DataStateManager +import com.atridad.openclimb.utils.DateFormatUtils import com.atridad.openclimb.utils.ZipExportImportUtils import java.io.File -import java.time.LocalDateTime import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.Json @@ -20,6 +21,10 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private val problemDao = database.problemDao() private val sessionDao = database.climbSessionDao() private val attemptDao = database.attemptDao() + private val dataStateManager = DataStateManager(context) + + // Callback interface for auto-sync functionality + private var autoSyncCallback: (() -> Unit)? = null private val json = Json { prettyPrint = true @@ -29,17 +34,41 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Gym operations fun getAllGyms(): Flow> = gymDao.getAllGyms() suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) - suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) - suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) - suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) + suspend fun insertGym(gym: Gym) { + gymDao.insertGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateGym(gym: Gym) { + gymDao.updateGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteGym(gym: Gym) { + gymDao.deleteGym(gym) + dataStateManager.updateDataState() + triggerAutoSync() + } // Problem operations fun getAllProblems(): Flow> = problemDao.getAllProblems() suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) - suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) - suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) - suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) + 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) + dataStateManager.updateDataState() + triggerAutoSync() + } // Session operations fun getAllSessions(): Flow> = sessionDao.getAllSessions() @@ -48,9 +77,21 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) sessionDao.getSessionsByGym(gymId) suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() - suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) - suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) - suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) + suspend fun insertSession(session: ClimbSession) { + sessionDao.insertSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateSession(session: ClimbSession) { + sessionDao.updateSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteSession(session: ClimbSession) { + sessionDao.deleteSession(session) + dataStateManager.updateDataState() + triggerAutoSync() + } suspend fun getLastUsedGym(): Gym? { val recentSessions = sessionDao.getRecentSessions(1).first() return if (recentSessions.isNotEmpty()) { @@ -66,9 +107,21 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) attemptDao.getAttemptsBySession(sessionId) fun getAttemptsByProblem(problemId: String): Flow> = attemptDao.getAttemptsByProblem(problemId) - suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) - suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) - suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) + suspend fun insertAttempt(attempt: Attempt) { + attemptDao.insertAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun updateAttempt(attempt: Attempt) { + attemptDao.updateAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() + } + suspend fun deleteAttempt(attempt: Attempt) { + attemptDao.deleteAttempt(attempt) + dataStateManager.updateDataState() + triggerAutoSync() + } suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) { try { @@ -84,7 +137,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Create backup data using platform-neutral format val backupData = ClimbDataBackup( - exportedAt = LocalDateTime.now().toString(), + exportedAt = DateFormatUtils.nowISO8601(), version = "2.0", formatVersion = "2.0", gyms = allGyms.map { BackupGym.fromGym(it) }, @@ -154,7 +207,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Import gyms first (problems depend on gyms) + // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data + // state updates importData.gyms.forEach { backupGym -> try { gymDao.insertGym(backupGym.toGym()) @@ -170,7 +224,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) importResult.importedImagePaths ) - // Import problems (depends on gyms) + // Import problems (depends on gyms) - use DAO directly updatedBackupProblems.forEach { backupProblem -> try { problemDao.insertProblem(backupProblem.toProblem()) @@ -181,7 +235,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import sessions + // Import sessions - use DAO directly importData.sessions.forEach { backupSession -> try { sessionDao.insertSession(backupSession.toClimbSession()) @@ -190,7 +244,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - // Import attempts last (depends on problems and sessions) + // Import attempts last (depends on problems and sessions) - use DAO directly importData.attempts.forEach { backupAttempt -> try { attemptDao.insertAttempt(backupAttempt.toAttempt()) @@ -198,11 +252,30 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") } } + + // Update data state once at the end to current time since we just imported new data + dataStateManager.updateDataState() } catch (e: Exception) { throw Exception("Import failed: ${e.message}") } } + /** + * Sets the callback for auto-sync functionality. This should be called by the SyncService to + * register itself for auto-sync triggers. + */ + fun setAutoSyncCallback(callback: (() -> Unit)?) { + autoSyncCallback = callback + } + + /** + * Triggers auto-sync if enabled. This is called after any data modification to keep data + * synchronized across devices automatically. + */ + private fun triggerAutoSync() { + autoSyncCallback?.invoke() + } + private fun validateDataIntegrity( gyms: List, problems: List, @@ -260,6 +333,10 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun resetAllData() { try { + // Temporarily disable auto-sync during reset + val originalCallback = autoSyncCallback + autoSyncCallback = null + // Clear all data from database attemptDao.deleteAllAttempts() sessionDao.deleteAllSessions() @@ -268,11 +345,35 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Clear all images from storage clearAllImages() + + // Restore auto-sync callback + autoSyncCallback = originalCallback } catch (e: Exception) { throw Exception("Reset failed: ${e.message}") } } + // Import methods that bypass auto-sync to avoid triggering sync during data restoration + suspend fun insertGymWithoutSync(gym: Gym) { + gymDao.insertGym(gym) + dataStateManager.updateDataState() + } + + suspend fun insertProblemWithoutSync(problem: Problem) { + problemDao.insertProblem(problem) + dataStateManager.updateDataState() + } + + suspend fun insertSessionWithoutSync(session: ClimbSession) { + sessionDao.insertSession(session) + dataStateManager.updateDataState() + } + + suspend fun insertAttemptWithoutSync(attempt: Attempt) { + attemptDao.insertAttempt(attempt) + dataStateManager.updateDataState() + } + private fun clearAllImages() { try { // Get the images directory diff --git a/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt new file mode 100644 index 0000000..7fedf7f --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/state/DataStateManager.kt @@ -0,0 +1,81 @@ +package com.atridad.openclimb.data.state + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.atridad.openclimb.utils.DateFormatUtils + +/** + * Manages the overall data state timestamp for sync purposes. This tracks when any data in the + * local database was last modified, independent of individual entity timestamps. + */ +class DataStateManager(context: Context) { + + companion object { + private const val TAG = "DataStateManager" + private const val PREFS_NAME = "openclimb_data_state" + private const val KEY_LAST_MODIFIED = "last_modified_timestamp" + private const val KEY_INITIALIZED = "state_initialized" + } + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + init { + // Initialize with current timestamp if this is the first time + if (!isInitialized()) { + updateDataState() + markAsInitialized() + Log.d(TAG, "DataStateManager initialized with timestamp: ${getLastModified()}") + } + } + + /** + * Updates the data state timestamp to the current time. Call this whenever any data is modified + * (create, update, delete). + */ + fun updateDataState() { + val now = DateFormatUtils.nowISO8601() + prefs.edit().putString(KEY_LAST_MODIFIED, now).apply() + Log.d(TAG, "Data state updated to: $now") + } + + /** + * Gets the current data state timestamp. This represents when any data was last modified + * locally. + */ + fun getLastModified(): String { + return prefs.getString(KEY_LAST_MODIFIED, DateFormatUtils.nowISO8601()) + ?: DateFormatUtils.nowISO8601() + } + + /** + * Sets the data state timestamp to a specific value. Used when importing data from server to + * sync the state. + */ + fun setLastModified(timestamp: String) { + prefs.edit().putString(KEY_LAST_MODIFIED, timestamp).apply() + Log.d(TAG, "Data state set to: $timestamp") + } + + /** Resets the data state (for testing or complete data wipe). */ + fun reset() { + prefs.edit().clear().apply() + Log.d(TAG, "Data state reset") + } + + /** Checks if the data state has been initialized. */ + private fun isInitialized(): Boolean { + return prefs.getBoolean(KEY_INITIALIZED, false) + } + + /** Marks the data state as initialized. */ + private fun markAsInitialized() { + prefs.edit().putBoolean(KEY_INITIALIZED, true).apply() + } + + /** Gets debug information about the current state. */ + fun getDebugInfo(): String { + return "DataState(lastModified=${getLastModified()}, initialized=${isInitialized()})" + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt new file mode 100644 index 0000000..9d193e6 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -0,0 +1,998 @@ +package com.atridad.openclimb.data.sync + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +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.migration.ImageMigrationService +import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.state.DataStateManager +import com.atridad.openclimb.utils.DateFormatUtils +import com.atridad.openclimb.utils.ImageNamingUtils +import com.atridad.openclimb.utils.ImageUtils +import java.io.IOException +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +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.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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 + +class SyncService(private val context: Context, private val repository: ClimbRepository) { + + private val migrationService = ImageMigrationService(context, repository) + private val dataStateManager = DataStateManager(context) + private val syncMutex = Mutex() + + companion object { + private const val TAG = "SyncService" + } + + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("sync_preferences", Context.MODE_PRIVATE) + + private val httpClient = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + coerceInputValues = true + } + + // State flows + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private val _lastSyncTime = MutableStateFlow(null) + val lastSyncTime: StateFlow = _lastSyncTime.asStateFlow() + + private val _syncError = MutableStateFlow(null) + val syncError: StateFlow = _syncError.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _isTesting = MutableStateFlow(false) + val isTesting: StateFlow = _isTesting.asStateFlow() + + // Configuration keys + private object Keys { + const val SERVER_URL = "sync_server_url" + const val AUTH_TOKEN = "sync_auth_token" + const val LAST_SYNC_TIME = "last_sync_time" + const val IS_CONNECTED = "sync_is_connected" + const val AUTO_SYNC_ENABLED = "auto_sync_enabled" + } + + // Configuration properties + var serverURL: String + get() = sharedPreferences.getString(Keys.SERVER_URL, "") ?: "" + set(value) { + sharedPreferences.edit().putString(Keys.SERVER_URL, value).apply() + } + + var authToken: String + get() = sharedPreferences.getString(Keys.AUTH_TOKEN, "") ?: "" + set(value) { + sharedPreferences.edit().putString(Keys.AUTH_TOKEN, value).apply() + } + + val isConfigured: Boolean + get() = serverURL.isNotEmpty() && authToken.isNotEmpty() + + var isAutoSyncEnabled: Boolean + get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) + set(value) { + sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply() + } + + init { + // Initialize state from preferences + _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) + _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) + + // Register auto-sync callback with repository + repository.setAutoSyncCallback { + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + triggerAutoSync() + } + } + } + + suspend fun downloadData(): ClimbDataBackup = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + val request = + Request.Builder() + .url("$serverURL/sync") + .get() + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Accept", "application/json") + .build() + + try { + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> { + val responseBody = + response.body?.string() + ?: throw SyncException.InvalidResponse( + "Empty response body" + ) + Log.d(TAG, "Downloaded data from server: ${responseBody.take(500)}...") + try { + val backup = json.decodeFromString(responseBody) + Log.d( + TAG, + "Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}" + ) + + // Log problems with images + backup.problems.forEach { problem -> + val imageCount = problem.imagePaths?.size ?: 0 + if (imageCount > 0) { + Log.d( + TAG, + "Server problem '${problem.name}' has images: ${problem.imagePaths}" + ) + } + } + + backup + } catch (e: Exception) { + Log.e(TAG, "Failed to decode download response: ${e.message}") + throw SyncException.DecodingError( + e.message ?: "Failed to decode response" + ) + } + } + 401 -> throw SyncException.Unauthorized + else -> throw SyncException.ServerError(response.code) + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun uploadData(backup: ClimbDataBackup): ClimbDataBackup = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + val jsonBody = json.encodeToString(backup) + Log.d(TAG, "Uploading JSON to server: $jsonBody") + val requestBody = jsonBody.toRequestBody("application/json".toMediaType()) + + val request = + Request.Builder() + .url("$serverURL/sync") + .put(requestBody) + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Content-Type", "application/json") + .build() + + try { + val response = httpClient.newCall(request).execute() + Log.d(TAG, "Upload response code: ${response.code}") + + when (response.code) { + 200 -> { + val responseBody = + response.body?.string() + ?: throw SyncException.InvalidResponse( + "Empty response body" + ) + try { + json.decodeFromString(responseBody) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode upload response: ${e.message}") + throw SyncException.DecodingError( + e.message ?: "Failed to decode response" + ) + } + } + 401 -> throw SyncException.Unauthorized + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e(TAG, "Server error ${response.code}: $errorBody") + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun uploadImage(filename: String, imageData: ByteArray) = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + // Server expects filename as query parameter and raw image data in body + // Extract just the filename without directory path + val justFilename = filename.substringAfterLast('/') + val requestBody = imageData.toRequestBody("image/*".toMediaType()) + + val request = + Request.Builder() + .url("$serverURL/images/upload?filename=$justFilename") + .post(requestBody) + .addHeader("Authorization", "Bearer $authToken") + .build() + + try { + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> Unit // Success + 401 -> throw SyncException.Unauthorized + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e(TAG, "Image upload error ${response.code}: $errorBody") + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun downloadImage(filename: String): ByteArray = + withContext(Dispatchers.IO) { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + Log.d(TAG, "Downloading image from server: $filename") + val request = + Request.Builder() + .url("$serverURL/images/download?filename=$filename") + .get() + .addHeader("Authorization", "Bearer $authToken") + .build() + + try { + val response = httpClient.newCall(request).execute() + Log.d(TAG, "Image download response for $filename: ${response.code}") + + when (response.code) { + 200 -> { + val imageBytes = + response.body?.bytes() + ?: throw SyncException.InvalidResponse( + "Empty image response" + ) + Log.d( + TAG, + "Successfully downloaded image $filename: ${imageBytes.size} bytes" + ) + imageBytes + } + 401 -> throw SyncException.Unauthorized + 404 -> { + Log.w(TAG, "Image not found on server: $filename") + throw SyncException.ImageNotFound(filename) + } + else -> { + val errorBody = response.body?.string() ?: "No error details" + Log.e( + TAG, + "Image download error ${response.code} for $filename: $errorBody" + ) + throw SyncException.ServerError(response.code) + } + } + } catch (e: IOException) { + Log.e(TAG, "Network error downloading image $filename: ${e.message}") + throw SyncException.NetworkError(e.message ?: "Network error") + } + } + + suspend fun syncWithServer() { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + if (!_isConnected.value) { + throw SyncException.NotConnected + } + + // Prevent concurrent sync operations + syncMutex.withLock { + _isSyncing.value = true + _syncError.value = null + + try { + // Fix existing image paths first + Log.d(TAG, "Fixing existing image paths before sync") + val pathFixSuccess = fixImagePaths() + if (!pathFixSuccess) { + Log.w(TAG, "Image path fix failed, but continuing with sync") + } + + // Migrate images to consistent naming second + Log.d(TAG, "Performing image migration before sync") + val migrationSuccess = migrateImagesForSync() + if (!migrationSuccess) { + Log.w(TAG, "Image migration failed, but continuing with sync") + } + + // Get local backup data + val localBackup = createBackupFromRepository() + + // Download server data + val serverBackup = downloadData() + + // Check if we have any local data + 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() + + when { + !hasLocalData && hasServerData -> { + // Case 1: No local data - do full restore from server + Log.d(TAG, "No local data found, performing full restore from server") + val imagePathMapping = syncImagesFromServer(serverBackup) + importBackupToRepository(serverBackup, imagePathMapping) + Log.d(TAG, "Full restore completed") + } + hasLocalData && !hasServerData -> { + // Case 2: No server data - upload local data to server + Log.d(TAG, "No server data found, uploading local data to server") + uploadData(localBackup) + syncImagesForBackup(localBackup) + Log.d(TAG, "Initial upload completed") + } + hasLocalData && hasServerData -> { + // Case 3: Both have data - compare timestamps (last writer wins) + val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) + val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) + + Log.d( + TAG, + "Comparing timestamps: local=$localTimestamp, server=$serverTimestamp" + ) + + if (localTimestamp > serverTimestamp) { + // Local is newer - replace server with local data + 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) { + // Server is newer - replace local with server data + 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 { + // Timestamps are equal - no sync needed + Log.d(TAG, "Data is in sync (timestamps equal), no action needed") + } + } + else -> { + Log.d(TAG, "No data to sync") + } + } + + // Update last sync time + val now = DateFormatUtils.nowISO8601() + _lastSyncTime.value = now + sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply() + } catch (e: Exception) { + _syncError.value = e.message + throw e + } finally { + _isSyncing.value = false + } + } + } + + private suspend fun syncImagesFromServer(backup: ClimbDataBackup): Map { + val imagePathMapping = mutableMapOf() + + Log.d(TAG, "Starting to download images from server") + var totalImages = 0 + var downloadedImages = 0 + var failedImages = 0 + + for (problem in backup.problems) { + val imageCount = problem.imagePaths?.size ?: 0 + if (imageCount > 0) { + Log.d( + TAG, + "Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}" + ) + totalImages += imageCount + } + + problem.imagePaths?.forEachIndexed { index, imagePath -> + try { + Log.d(TAG, "Attempting to download image: $imagePath") + val imageData = downloadImage(imagePath) + + // Extract filename and ensure it follows our naming convention + val serverFilename = imagePath.substringAfterLast('/') + val consistentFilename = + if (ImageNamingUtils.isValidImageFilename(serverFilename)) { + serverFilename + } else { + // Generate consistent filename using problem ID and index + ImageNamingUtils.generateImageFilename(problem.id, index) + } + + val localImagePath = + ImageUtils.saveImageFromBytesWithFilename( + context, + imageData, + consistentFilename + ) + + if (localImagePath != null) { + // Map original server filename to the full local relative path + imagePathMapping[serverFilename] = localImagePath + downloadedImages++ + Log.d( + TAG, + "Downloaded and mapped image: $serverFilename -> $localImagePath" + ) + } else { + Log.w(TAG, "Failed to save downloaded image locally: $imagePath") + failedImages++ + } + } catch (e: Exception) { + Log.w(TAG, "Failed to download image $imagePath: ${e.message}") + failedImages++ + } + } + } + + Log.d( + TAG, + "Image download completed: $downloadedImages downloaded, $failedImages failed, $totalImages total" + ) + return imagePathMapping + } + + private suspend fun syncImagesToServer() { + val allProblems = repository.getAllProblems().first() + val backup = + ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + version = "2.0", + formatVersion = "2.0", + gyms = emptyList(), + problems = allProblems.map { BackupProblem.fromProblem(it) }, + sessions = emptyList(), + attempts = emptyList() + ) + syncImagesForBackup(backup) + } + + private suspend fun syncImagesForBackup(backup: ClimbDataBackup) { + Log.d(TAG, "Starting image sync for backup with ${backup.problems.size} problems") + + var totalImages = 0 + var uploadedImages = 0 + var failedImages = 0 + + for (problem in backup.problems) { + val imageCount = problem.imagePaths?.size ?: 0 + totalImages += imageCount + + Log.d(TAG, "Problem '${problem.name}' has $imageCount images: ${problem.imagePaths}") + + problem.imagePaths?.forEachIndexed { index, imagePath -> + try { + val imageFile = ImageUtils.getImageFile(context, imagePath) + Log.d(TAG, "Checking image file: $imagePath -> ${imageFile.absolutePath}") + Log.d( + TAG, + "Image file exists: ${imageFile.exists()}, size: ${if (imageFile.exists()) imageFile.length() else 0} bytes" + ) + + if (imageFile.exists() && imageFile.length() > 0) { + val imageData = imageFile.readBytes() + val filename = imagePath.substringAfterLast('/') + + // Ensure filename follows our naming convention + val consistentFilename = + if (ImageNamingUtils.isValidImageFilename(filename)) { + filename + } else { + // Generate consistent filename and rename the local file + val newFilename = + ImageNamingUtils.generateImageFilename( + problem.id, + index + ) + val newFile = java.io.File(imageFile.parent, newFilename) + if (imageFile.renameTo(newFile)) { + Log.d( + TAG, + "Renamed local image file: $filename -> $newFilename" + ) + // Update the problem's image path in memory for next sync + newFilename + } else { + Log.w( + TAG, + "Failed to rename local image file, using original" + ) + filename + } + } + + Log.d(TAG, "Uploading image: $consistentFilename (${imageData.size} bytes)") + uploadImage(consistentFilename, imageData) + uploadedImages++ + Log.d(TAG, "Successfully uploaded image: $consistentFilename") + } else { + Log.w( + TAG, + "Image file not found or empty: $imagePath at ${imageFile.absolutePath}" + ) + failedImages++ + } + } catch (e: Exception) { + Log.e(TAG, "Failed to upload image $imagePath: ${e.message}", e) + failedImages++ + } + } + } + + Log.d( + TAG, + "Image sync completed: $uploadedImages uploaded, $failedImages failed, $totalImages total" + ) + } + + private suspend fun createBackupFromRepository(): ClimbDataBackup { + val allGyms = repository.getAllGyms().first() + val allProblems = repository.getAllProblems().first() + val allSessions = repository.getAllSessions().first() + val allAttempts = repository.getAllAttempts().first() + + return ClimbDataBackup( + exportedAt = dataStateManager.getLastModified(), + version = "2.0", + formatVersion = "2.0", + gyms = allGyms.map { BackupGym.fromGym(it) }, + problems = allProblems.map { BackupProblem.fromProblem(it) }, + sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) }, + attempts = allAttempts.map { BackupAttempt.fromAttempt(it) } + ) + } + + private suspend fun importBackupToRepository( + backup: ClimbDataBackup, + imagePathMapping: Map = emptyMap() + ) { + // Clear existing data to avoid conflicts + repository.resetAllData() + + // Import gyms first (problems depend on gyms) + backup.gyms.forEach { backupGym -> + try { + val gym = backupGym.toGym() + Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") + repository.insertGymWithoutSync(gym) + } catch (e: Exception) { + Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") + throw e // Stop import if gym fails since problems depend on it + } + } + + // Import problems with updated image paths + backup.problems.forEach { backupProblem -> + try { + val updatedProblem = + if (imagePathMapping.isNotEmpty()) { + val newImagePaths = + backupProblem.imagePaths?.mapNotNull { oldPath -> + // Extract filename and check mapping + val filename = oldPath.substringAfterLast('/') + // Use mapped full path or fallback to consistent naming + // with full path + imagePathMapping[filename] + ?: if (ImageNamingUtils.isValidImageFilename( + filename + ) + ) { + "problem_images/$filename" + } else { + // Generate consistent filename as fallback with + // full path + 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()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}") + } + } + + // Import sessions + backup.sessions.forEach { backupSession -> + try { + repository.insertSessionWithoutSync(backupSession.toClimbSession()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}") + } + } + + // Import attempts last + backup.attempts.forEach { backupAttempt -> + try { + repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) + } catch (e: Exception) { + Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}") + } + } + + // Update local data state to match imported data timestamp + dataStateManager.setLastModified(backup.exportedAt) + Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") + } + + /** Parses ISO8601 timestamp to milliseconds for comparison */ + private fun parseISO8601ToMillis(timestamp: String): Long { + return try { + Instant.parse(timestamp).toEpochMilli() + } catch (e: Exception) { + Log.w(TAG, "Failed to parse timestamp: $timestamp, using 0", e) + 0L + } + } + + /** Converts milliseconds to ISO8601 timestamp */ + private fun millisToISO8601(millis: Long): String { + return DateFormatUtils.millisToISO8601(millis) + } + + /** + * Fixes existing image paths in the database to include the proper directory structure. This + * corrects paths like "problem_abc_0.jpg" to "problem_images/problem_abc_0.jpg" + */ + suspend fun fixImagePaths(): Boolean { + return try { + Log.d(TAG, "Fixing existing image paths in database") + + val allProblems = repository.getAllProblems().first() + var fixedCount = 0 + + for (problem in allProblems) { + if (problem.imagePaths.isNotEmpty()) { + val originalPaths = problem.imagePaths + val fixedPaths = + problem.imagePaths.map { path -> + if (!path.startsWith("problem_images/") && !path.contains("/")) { + // Just a filename, add the directory prefix + val fixedPath = "problem_images/$path" + Log.d(TAG, "Fixed path: $path -> $fixedPath") + fixedCount++ + fixedPath + } else { + path + } + } + + if (originalPaths != fixedPaths) { + val updatedProblem = problem.copy(imagePaths = fixedPaths) + repository.insertProblem(updatedProblem) + } + } + } + + Log.i(TAG, "Fixed $fixedCount image paths in database") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to fix image paths: ${e.message}", e) + false + } + } + + /** + * Performs image migration to ensure all images use consistent naming convention before sync + * operations. This should be called before any sync to avoid filename conflicts. + */ + suspend fun migrateImagesForSync(): Boolean { + return try { + Log.d(TAG, "Starting image migration for sync compatibility") + val result = migrationService.performFullMigration() + + when (result) { + is com.atridad.openclimb.data.migration.ImageMigrationResult.AlreadyCompleted -> { + Log.d(TAG, "Image migration already completed") + true + } + is com.atridad.openclimb.data.migration.ImageMigrationResult.Success -> { + Log.i( + TAG, + "Image migration completed: ${result.totalMigrated} images migrated, ${result.errors} errors" + ) + true + } + is com.atridad.openclimb.data.migration.ImageMigrationResult.Failed -> { + Log.e(TAG, "Image migration failed: ${result.error}") + false + } + } + } catch (e: Exception) { + Log.e(TAG, "Image migration error: ${e.message}", e) + false + } + } + + suspend fun testConnection() { + if (!isConfigured) { + throw SyncException.NotConfigured + } + + _isTesting.value = true + _syncError.value = null + + try { + withContext(Dispatchers.IO) { + val request = + Request.Builder() + .url("$serverURL/sync") + .get() + .addHeader("Authorization", "Bearer $authToken") + .addHeader("Accept", "application/json") + .build() + + val response = httpClient.newCall(request).execute() + + when (response.code) { + 200 -> { + _isConnected.value = true + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, true).apply() + } + 401 -> throw SyncException.Unauthorized + else -> throw SyncException.ServerError(response.code) + } + } + } catch (e: Exception) { + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() + _syncError.value = e.message + throw e + } finally { + _isTesting.value = false + } + } + + suspend fun triggerAutoSync() { + if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) { + return + } + + // Check if sync is already running to prevent duplicate attempts + if (_isSyncing.value) { + Log.d(TAG, "Sync already in progress, skipping auto-sync") + return + } + + try { + syncWithServer() + } catch (e: Exception) { + Log.e(TAG, "Auto-sync failed: ${e.message}") + _syncError.value = e.message + } + } + + // DEPRECATED: Complex merge logic replaced with simple timestamp-based sync + // These methods are no longer used but kept for reference + @Deprecated("Use simple timestamp-based sync instead") + private fun performIntelligentMerge( + local: ClimbDataBackup, + server: ClimbDataBackup + ): ClimbDataBackup { + Log.d(TAG, "Merging data - preserving all entities to prevent data loss") + + val mergedGyms = mergeGyms(local.gyms, server.gyms) + val mergedProblems = mergeProblems(local.problems, server.problems) + val mergedSessions = mergeSessions(local.sessions, server.sessions) + val mergedAttempts = mergeAttempts(local.attempts, server.attempts) + + Log.d( + TAG, + "Merge results: gyms=${mergedGyms.size}, problems=${mergedProblems.size}, " + + "sessions=${mergedSessions.size}, attempts=${mergedAttempts.size}" + ) + + return ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), + version = "2.0", + formatVersion = "2.0", + gyms = mergedGyms, + problems = mergedProblems, + sessions = mergedSessions, + attempts = mergedAttempts + ) + } + + private fun mergeGyms(local: List, server: List): List { + val merged = mutableMapOf() + + // Add all local gyms + local.forEach { gym -> merged[gym.id] = gym } + + // Add server gyms, preferring newer updates + server.forEach { serverGym -> + val localGym = merged[serverGym.id] + if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) { + merged[serverGym.id] = serverGym + } + } + + return merged.values.toList() + } + + private fun mergeProblems( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local problems + local.forEach { problem -> merged[problem.id] = problem } + + // Add server problems, preferring newer updates + server.forEach { serverProblem -> + val localProblem = merged[serverProblem.id] + if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ) { + // Merge image paths to preserve all images + val allImagePaths = mutableSetOf() + localProblem?.imagePaths?.let { allImagePaths.addAll(it) } + serverProblem.imagePaths?.let { allImagePaths.addAll(it) } + + merged[serverProblem.id] = + serverProblem.withUpdatedImagePaths(allImagePaths.toList()) + } + } + + return merged.values.toList() + } + + private fun mergeSessions( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local sessions + local.forEach { session -> merged[session.id] = session } + + // Add server sessions, preferring newer updates + server.forEach { serverSession -> + val localSession = merged[serverSession.id] + if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt) + ) { + merged[serverSession.id] = serverSession + } + } + + return merged.values.toList() + } + + private fun mergeAttempts( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local attempts + local.forEach { attempt -> merged[attempt.id] = attempt } + + // Add server attempts, preferring newer updates + server.forEach { serverAttempt -> + val localAttempt = merged[serverAttempt.id] + if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) + ) { + merged[serverAttempt.id] = serverAttempt + } + } + + return merged.values.toList() + } + + private fun isNewerThan(dateString1: String, dateString2: String): Boolean { + return try { + // Try parsing as instant first + val date1 = Instant.parse(dateString1) + val date2 = Instant.parse(dateString2) + date1.isAfter(date2) + } catch (e: Exception) { + // Fallback to string comparison + dateString1 > dateString2 + } + } + + fun disconnect() { + _isConnected.value = false + sharedPreferences.edit().putBoolean(Keys.IS_CONNECTED, false).apply() + _syncError.value = null + } + + fun clearConfiguration() { + serverURL = "" + authToken = "" + isAutoSyncEnabled = true + _lastSyncTime.value = null + _isConnected.value = false + _syncError.value = null + + sharedPreferences.edit().clear().apply() + } +} + +// Removed SyncTrigger enum - now using simple auto sync on any data change + +sealed class SyncException(message: String) : Exception(message) { + object NotConfigured : + SyncException("Sync is not configured. Please set server URL and auth token.") + object NotConnected : SyncException("Not connected to server. Please test connection first.") + object Unauthorized : SyncException("Unauthorized. Please check your auth token.") + object InvalidURL : SyncException("Invalid server URL.") + data class ServerError(val code: Int) : SyncException("Server error: HTTP $code") + data class InvalidResponse(val details: String) : + SyncException("Invalid server response: $details") + data class DecodingError(val details: String) : + SyncException("Failed to decode server response: $details") + data class ImageNotFound(val filename: String) : SyncException("Image not found: $filename") + data class NetworkError(val details: String) : SyncException("Network error: $details") +} diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt index 83ec388..a8bbca2 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/OpenClimbApp.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -20,6 +19,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService import com.atridad.openclimb.navigation.Screen import com.atridad.openclimb.navigation.bottomNavigationItems import com.atridad.openclimb.ui.components.NotificationPermissionDialog @@ -43,7 +43,9 @@ fun OpenClimbApp( val database = remember { OpenClimbDatabase.getDatabase(context) } val repository = remember { ClimbRepository(database, context) } - val viewModel: ClimbViewModel = viewModel(factory = ClimbViewModelFactory(repository)) + val syncService = remember { SyncService(context, repository) } + val viewModel: ClimbViewModel = + viewModel(factory = ClimbViewModelFactory(repository, syncService)) // Notification permission state var showNotificationPermissionDialog by remember { mutableStateOf(false) } @@ -73,6 +75,9 @@ fun OpenClimbApp( LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) } + // Trigger auto-sync on app launch + LaunchedEffect(Unit) { syncService.triggerAutoSync() } + val activeSession by viewModel.activeSession.collectAsState() val gyms by viewModel.gyms.collectAsState() diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index f87eb53..d5ea537 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -18,412 +18,811 @@ import androidx.compose.ui.unit.dp import com.atridad.openclimb.R import com.atridad.openclimb.ui.viewmodel.ClimbViewModel import java.io.File +import java.time.Instant +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen( - viewModel: ClimbViewModel -) { +fun SettingsScreen(viewModel: ClimbViewModel) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current - - // State for reset confirmation dialog + val coroutineScope = rememberCoroutineScope() + + // Sync service state + val syncService = viewModel.syncService + val isSyncing by syncService.isSyncing.collectAsState() + val isConnected by syncService.isConnected.collectAsState() + val isTesting by syncService.isTesting.collectAsState() + val lastSyncTime by syncService.lastSyncTime.collectAsState() + val syncError by syncService.syncError.collectAsState() + + // State for dialogs var showResetDialog by remember { mutableStateOf(false) } - - val packageInfo = remember { - context.packageManager.getPackageInfo(context.packageName, 0) - } + var showSyncConfigDialog by remember { mutableStateOf(false) } + var showDisconnectDialog by remember { mutableStateOf(false) } + + // Sync configuration state + var serverUrl by remember { mutableStateOf(syncService.serverURL) } + var authToken by remember { mutableStateOf(syncService.authToken) } + + val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val appVersion = packageInfo.versionName - + // File picker launcher for import - only accepts ZIP files - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri -> - uri?.let { - try { - val inputStream = context.contentResolver.openInputStream(uri) - // Determine file extension from content resolver - val fileName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0 && cursor.moveToFirst()) { - cursor.getString(nameIndex) - } else null - } ?: "import_file" - - // Only allow ZIP files - if (!fileName.lowercase().endsWith(".zip")) { - viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.") - return@let - } - - val tempFile = File(context.cacheDir, "temp_import.zip") - - inputStream?.use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) + val importLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri + -> + uri?.let { + try { + val inputStream = context.contentResolver.openInputStream(uri) + // Determine file extension from content resolver + val fileName = + context.contentResolver.query(uri, null, null, null, null)?.use { + cursor -> + val nameIndex = + cursor.getColumnIndex( + android.provider.OpenableColumns.DISPLAY_NAME + ) + if (nameIndex >= 0 && cursor.moveToFirst()) { + cursor.getString(nameIndex) + } else null + } + ?: "import_file" + + // Only allow ZIP files + if (!fileName.lowercase().endsWith(".zip")) { + viewModel.setError( + "Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb." + ) + return@let + } + + val tempFile = File(context.cacheDir, "temp_import.zip") + + inputStream?.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + viewModel.importData(tempFile) + } catch (e: Exception) { + viewModel.setError("Failed to read file: ${e.message}") } } - viewModel.importData(tempFile) - } catch (e: Exception) { - viewModel.setError("Failed to read file: ${e.message}") } - } - } - + // File picker launcher for export - ZIP format with images - val exportZipLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/zip") - ) { uri -> - uri?.let { - try { - viewModel.exportDataToZipUri(context, uri) - } catch (e: Exception) { - viewModel.setError("Failed to save file: ${e.message}") + val exportZipLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + try { + viewModel.exportDataToZipUri(context, uri) + } catch (e: Exception) { + viewModel.setError("Failed to save file: ${e.message}") + } + } } - } - } - + LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { item { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Icon( - painter = painterResource(id = R.drawable.ic_mountains), - contentDescription = "OpenClimb Logo", - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + painter = painterResource(id = R.drawable.ic_mountains), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary ) Text( - text = "Settings", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Settings", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold ) } } - + // Data Management Section item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "Data Management", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Data Management", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(12.dp)) - - // Export Data + + // Export Data Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Export Data with Images") }, - supportingContent = { Text("Export all your climbing data and images to ZIP file (recommended)") }, - leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, - trailingContent = { - TextButton( - onClick = { - val defaultFileName = "openclimb_export_${ + headlineContent = { Text("Export Data with Images") }, + supportingContent = { + Text( + "Export all your climbing data and images to ZIP file (recommended)" + ) + }, + leadingContent = { + Icon(Icons.Default.Share, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { + val defaultFileName = + "openclimb_export_${ java.time.LocalDateTime.now() .toString() .replace(":", "-") .replace(".", "-") }.zip" - exportZipLauncher.launch(defaultFileName) - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Export ZIP") + exportZipLauncher.launch(defaultFileName) + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Export ZIP") + } } } - } ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Import Data") }, - supportingContent = { Text("Import climbing data from ZIP file (recommended format)") }, - leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, - trailingContent = { - TextButton( - onClick = { - importLauncher.launch("application/zip") - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Import") + headlineContent = { Text("Import Data") }, + supportingContent = { + Text("Import climbing data from ZIP file (recommended format)") + }, + leadingContent = { + Icon(Icons.Default.Add, contentDescription = null) + }, + trailingContent = { + TextButton( + onClick = { importLauncher.launch("application/zip") }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Import") + } } } - } ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Reset All Data") }, - supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") }, - leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, - trailingContent = { - TextButton( - onClick = { - showResetDialog = true - }, - enabled = !uiState.isLoading - ) { - if (uiState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Text("Reset", color = MaterialTheme.colorScheme.error) + headlineContent = { Text("Reset All Data") }, + supportingContent = { + Text( + "Permanently delete all gyms, problems, sessions, attempts, and images" + ) + }, + leadingContent = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + trailingContent = { + TextButton( + onClick = { showResetDialog = true }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Reset", color = MaterialTheme.colorScheme.error) + } } } - } ) } } } } - + + // Sync Section + item { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "Sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (syncService.isConfigured) { + // Connected state + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isConnected) + MaterialTheme.colorScheme + .primaryContainer.copy( + alpha = 0.3f + ) + else + MaterialTheme.colorScheme + .surfaceVariant.copy( + alpha = 0.3f + ) + ) + ) { + ListItem( + headlineContent = { + Text( + if (isConnected) "Connected to Server" + else "Server Configured" + ) + }, + supportingContent = { + Column { + Text("Server: ${syncService.serverURL}") + lastSyncTime?.let { time -> + Text( + "Last sync: ${ + try { + Instant.parse(time).toString() + } catch (e: Exception) { + time + } + }", + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + leadingContent = { + Icon( + if (isConnected) Icons.Default.CloudDone + else Icons.Default.Cloud, + contentDescription = null, + tint = + if (isConnected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme + .onSurfaceVariant + ) + }, + trailingContent = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Manual Sync Button + TextButton( + onClick = { + coroutineScope.launch { + viewModel.performManualSync() + } + }, + enabled = isConnected && !isSyncing + ) { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Sync") + } + } + + // Configure Button + TextButton(onClick = { showSyncConfigDialog = true }) { + Text("Configure") + } + } + } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Auto-sync settings + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Sync Mode", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Auto-sync") + Text( + text = + "Sync automatically on app launch and data changes", + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.7f + ), + maxLines = 2 + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = syncService.isAutoSyncEnabled, + onCheckedChange = { syncService.isAutoSyncEnabled = it } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Disconnect option + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer + .copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Disconnect") }, + supportingContent = { Text("Clear sync configuration") }, + leadingContent = { + Icon( + Icons.Default.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + trailingContent = { + TextButton(onClick = { showDisconnectDialog = true }) { + Text( + "Disconnect", + color = MaterialTheme.colorScheme.error + ) + } + } + ) + } + } else { + // Not configured state + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant + .copy(alpha = 0.3f) + ) + ) { + ListItem( + headlineContent = { Text("Setup Sync") }, + supportingContent = { + Text("Connect to your OpenClimb sync server") + }, + leadingContent = { + Icon(Icons.Default.CloudSync, contentDescription = null) + }, + trailingContent = { + TextButton(onClick = { showSyncConfigDialog = true }) { + Text("Setup") + } + } + ) + } + } + + // Show sync error if any + syncError?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Card( + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } + } + } + // App Information Section item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text( - text = "App Information", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "App Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) - + Spacer(modifier = Modifier.height(12.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_mountains), - contentDescription = "OpenClimb Logo", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text("OpenClimb") - } - }, - supportingContent = { Text("Track your climbing progress") }, - leadingContent = { } + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = + painterResource( + id = R.drawable.ic_mountains + ), + contentDescription = "OpenClimb Logo", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text("OpenClimb") + } + }, + supportingContent = { Text("Track your climbing progress") }, + leadingContent = {} ) } - + Spacer(modifier = Modifier.height(8.dp)) - + Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) ) { ListItem( - headlineContent = { Text("Version") }, - supportingContent = { Text(appVersion ?: "Unknown") }, - leadingContent = { Icon(Icons.Default.Info, contentDescription = null) } + headlineContent = { Text("Version") }, + supportingContent = { Text(appVersion ?: "Unknown") }, + leadingContent = { + Icon(Icons.Default.Info, contentDescription = null) + } ) } } } } - - } - + // Show loading/message states if (uiState.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } - + uiState.message?.let { message -> LaunchedEffect(message) { kotlinx.coroutines.delay(5000) viewModel.clearMessage() } - + Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(12.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer ) } } } - + uiState.error?.let { error -> LaunchedEffect(error) { kotlinx.coroutines.delay(5000) viewModel.clearError() } - + Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer ) } } } - + // Reset confirmation dialog if (showResetDialog) { AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { Text("Reset All Data") }, - text = { - Column { - Text("Are you sure you want to reset all data?") - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This will permanently delete:", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "This action cannot be undone. Consider exporting your data first.", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error - ) - } - }, - confirmButton = { - TextButton( - onClick = { - viewModel.resetAllData() - showResetDialog = false + onDismissRequest = { showResetDialog = false }, + title = { Text("Reset All Data") }, + text = { + Column { + Text("Are you sure you want to reset all data?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will permanently delete:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "This action cannot be undone. Consider exporting your data first.", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) } - ) { - Text("Reset All Data", color = MaterialTheme.colorScheme.error) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.resetAllData() + showResetDialog = false + } + ) { Text("Reset All Data", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { Text("Cancel") } } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { - Text("Cancel") + ) + } + + // Sync Configuration Dialog + if (showSyncConfigDialog) { + AlertDialog( + onDismissRequest = { showSyncConfigDialog = false }, + title = { Text("Sync Configuration") }, + text = { + Column { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("https://your-server.com") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = authToken, + onValueChange = { authToken = it }, + label = { Text("Auth Token") }, + placeholder = { Text("your-secret-token") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (syncService.isConfigured) { + Text( + text = "Test connection before enabling sync features", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = "Enter your server URL and auth token to enable sync", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + Row { + if (syncService.isConfigured) { + TextButton( + onClick = { + coroutineScope.launch { + try { + // Save configuration first + syncService.serverURL = serverUrl.trim() + syncService.authToken = authToken.trim() + viewModel.testSyncConnection() + showSyncConfigDialog = false + } catch (e: Exception) { + // Error will be shown via syncError state + } + } + }, + enabled = + !isTesting && + serverUrl.isNotBlank() && + authToken.isNotBlank() + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Test Connection") + } + } + + Spacer(modifier = Modifier.width(8.dp)) + } + + TextButton( + onClick = { + syncService.serverURL = serverUrl.trim() + syncService.authToken = authToken.trim() + showSyncConfigDialog = false + }, + enabled = serverUrl.isNotBlank() && authToken.isNotBlank() + ) { Text("Save") } + } + }, + dismissButton = { + TextButton( + onClick = { + // Reset to current values + serverUrl = syncService.serverURL + authToken = syncService.authToken + showSyncConfigDialog = false + } + ) { Text("Cancel") } + } + ) + } + + // Disconnect Dialog + if (showDisconnectDialog) { + AlertDialog( + onDismissRequest = { showDisconnectDialog = false }, + title = { Text("Disconnect from Sync") }, + text = { + Text( + "Are you sure you want to disconnect from the sync server? This will clear your server configuration and disable auto-sync." + ) + }, + confirmButton = { + TextButton( + onClick = { + syncService.clearConfiguration() + serverUrl = "" + authToken = "" + showDisconnectDialog = false + } + ) { Text("Disconnect", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") } } - } ) } } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 024f789..87c243f 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService import com.atridad.openclimb.service.SessionTrackingService import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.SessionShareUtils @@ -15,7 +16,8 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { +class ClimbViewModel(private val repository: ClimbRepository, val syncService: SyncService) : + ViewModel() { // UI State flows private val _uiState = MutableStateFlow(ClimbUiState()) @@ -112,6 +114,7 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { viewModelScope.launch { repository.insertProblem(problem) ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback } } @@ -265,6 +268,8 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback + _uiState.value = _uiState.value.copy(message = "Session completed!") } } @@ -290,6 +295,7 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { viewModelScope.launch { repository.insertAttempt(attempt) ClimbStatsWidgetProvider.updateAllWidgets(context) + // Auto-sync now happens automatically via repository callback } } @@ -383,6 +389,23 @@ class ClimbViewModel(private val repository: ClimbRepository) : ViewModel() { _uiState.value = _uiState.value.copy(error = null) } + // Sync-related methods + suspend fun performManualSync() { + try { + syncService.syncWithServer() + } catch (e: Exception) { + setError("Sync failed: ${e.message}") + } + } + + suspend fun testSyncConnection() { + try { + syncService.testConnection() + } catch (e: Exception) { + setError("Connection test failed: ${e.message}") + } + } + fun setError(message: String) { _uiState.value = _uiState.value.copy(error = message) } diff --git a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt index 1fe048e..ad928b4 100644 --- a/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt +++ b/android/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModelFactory.kt @@ -3,15 +3,17 @@ package com.atridad.openclimb.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.atridad.openclimb.data.repository.ClimbRepository +import com.atridad.openclimb.data.sync.SyncService class ClimbViewModelFactory( - private val repository: ClimbRepository + private val repository: ClimbRepository, + private val syncService: SyncService ) : ViewModelProvider.Factory { - + @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) { - return ClimbViewModel(repository) as T + return ClimbViewModel(repository, syncService) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt new file mode 100644 index 0000000..d7efbb9 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/utils/DateFormatUtils.kt @@ -0,0 +1,68 @@ +package com.atridad.openclimb.utils + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +object DateFormatUtils { + + /** + * ISO 8601 formatter matching iOS date format exactly Produces dates like: + * "2025-09-07T22:00:40.014Z" + */ + private val ISO_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC) + + /** + * Get current timestamp in iOS-compatible ISO 8601 format + * @return Current timestamp as "2025-09-07T22:00:40.014Z" + */ + fun nowISO8601(): String { + return ISO_FORMATTER.format(Instant.now()) + } + + /** + * Format an Instant to iOS-compatible ISO 8601 format + * @param instant The instant to format + * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" + */ + fun formatISO8601(instant: Instant): String { + return ISO_FORMATTER.format(instant) + } + + /** + * Parse an iOS-compatible ISO 8601 date string back to Instant + * @param dateString ISO 8601 formatted date string + * @return Instant object, or null if parsing fails + */ + fun parseISO8601(dateString: String): Instant? { + return try { + Instant.from(ISO_FORMATTER.parse(dateString)) + } catch (e: Exception) { + // Fallback - try standard Instant parsing + try { + Instant.parse(dateString) + } catch (e2: Exception) { + null + } + } + } + + /** + * Validate that a date string matches the expected iOS format + * @param dateString The date string to validate + * @return True if the format matches iOS expectations + */ + fun isValidISO8601(dateString: String): Boolean { + return parseISO8601(dateString) != null + } + + /** + * Convert milliseconds timestamp to iOS-compatible ISO 8601 format + * @param millis Milliseconds since epoch + * @return Formatted timestamp as "2025-09-07T22:00:40.014Z" + */ + fun millisToISO8601(millis: Long): String { + return ISO_FORMATTER.format(Instant.ofEpochMilli(millis)) + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt new file mode 100644 index 0000000..94eac0f --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageNamingUtils.kt @@ -0,0 +1,147 @@ +package com.atridad.openclimb.utils + +import java.security.MessageDigest +import java.util.* + +/** + * Utility for creating consistent image filenames across iOS and Android platforms. Uses + * deterministic naming based on problem ID and timestamp to ensure sync compatibility. + */ +object ImageNamingUtils { + + private const val IMAGE_EXTENSION = ".jpg" + private const val HASH_LENGTH = 12 // First 12 chars of SHA-256 + + /** + * Generates a deterministic filename for a problem image. Format: + * "problem_{problemId}_{timestamp}_{index}.jpg" + * + * @param problemId The ID of the problem this image belongs to + * @param timestamp ISO8601 timestamp when the image was created + * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) + * @return A consistent filename that will be the same across platforms + */ + fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String { + // Create a deterministic hash from problemId + timestamp + index + val input = "${problemId}_${timestamp}_${imageIndex}" + val hash = createHash(input) + + return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}" + } + + /** + * Generates a deterministic filename for a problem image using current timestamp. + * + * @param problemId The ID of the problem this image belongs to + * @param imageIndex The index of this image for the problem (0, 1, 2, etc.) + * @return A consistent filename + */ + fun generateImageFilename(problemId: String, imageIndex: Int): String { + val timestamp = DateFormatUtils.nowISO8601() + return generateImageFilename(problemId, timestamp, imageIndex) + } + + /** + * Extracts problem ID from an image filename created by this utility. Returns null if the + * filename doesn't match our naming convention. + * + * @param filename The image filename + * @return The problem ID or null if not a valid filename + */ + fun extractProblemIdFromFilename(filename: String): String? { + if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { + return null + } + + // Format: problem_{hash}_{index}.jpg + val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) + val parts = nameWithoutExtension.split("_") + + if (parts.size != 3 || parts[0] != "problem") { + return null + } + + // We can't extract the original problem ID from the hash, + // but we can validate the format + return parts[1] // Return the hash as identifier + } + + /** + * Validates if a filename follows our naming convention. + * + * @param filename The filename to validate + * @return true if it matches our convention, false otherwise + */ + fun isValidImageFilename(filename: String): Boolean { + if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) { + return false + } + + val nameWithoutExtension = filename.substring(0, filename.length - IMAGE_EXTENSION.length) + val parts = nameWithoutExtension.split("_") + + return parts.size == 3 && + parts[0] == "problem" && + parts[1].length == HASH_LENGTH && + parts[2].toIntOrNull() != null + } + + /** + * Migrates an existing UUID-based filename to our naming convention. This is used during sync + * to rename downloaded images. + * + * @param oldFilename The existing filename (UUID-based) + * @param problemId The problem ID this image belongs to + * @param imageIndex The index of this image + * @return The new filename following our convention + */ + fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String { + // If it's already using our convention, keep it + if (isValidImageFilename(oldFilename)) { + return oldFilename + } + + // Generate new deterministic name + // Use a timestamp based on the old filename to maintain some consistency + val timestamp = DateFormatUtils.nowISO8601() + return generateImageFilename(problemId, timestamp, imageIndex) + } + + /** + * Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters + * for filename safety. + * + * @param input The input string to hash + * @return First 12 characters of SHA-256 hash in lowercase + */ + private fun createHash(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + val hashHex = hashBytes.joinToString("") { "%02x".format(it) } + return hashHex.take(HASH_LENGTH) + } + + /** + * Batch renames images for a problem to use our naming convention. Returns a mapping of old + * filename -> new filename. + * + * @param problemId The problem ID + * @param existingFilenames List of current image filenames for this problem + * @return Map of old filename to new filename + */ + fun batchRenameForProblem( + problemId: String, + existingFilenames: List + ): Map { + val renameMap = mutableMapOf() + + existingFilenames.forEachIndexed { index, oldFilename -> + val newFilename = migrateFilename(oldFilename, problemId, index) + if (newFilename != oldFilename) { + renameMap[oldFilename] = newFilename + } + } + + return renameMap + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt index e3448ca..78fb3cd 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ImageUtils.kt @@ -5,20 +5,18 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import androidx.core.graphics.scale import java.io.File import java.io.FileOutputStream import java.util.UUID -import androidx.core.graphics.scale object ImageUtils { - + private const val IMAGES_DIR = "problem_images" private const val MAX_IMAGE_SIZE = 1024 private const val IMAGE_QUALITY = 85 - - /** - * Creates the images directory if it doesn't exist - */ + + /** Creates the images directory if it doesn't exist */ private fun getImagesDirectory(context: Context): File { val imagesDir = File(context.filesDir, IMAGES_DIR) if (!imagesDir.exists()) { @@ -26,25 +24,39 @@ object ImageUtils { } return imagesDir } - + /** - * Saves an image from URI to app's private storage with compression + * Saves an image from a URI with compression and proper orientation * @param context Android context * @param imageUri URI of the image to save + * @param problemId The problem ID this image belongs to (optional) + * @param imageIndex The index of this image for the problem (optional) * @return The relative file path if successful, null otherwise */ - fun saveImageFromUri(context: Context, imageUri: Uri): String? { + fun saveImageFromUri( + context: Context, + imageUri: Uri, + problemId: String? = null, + imageIndex: Int? = null + ): String? { return try { // Decode bitmap from a fresh stream to avoid mark/reset dependency - val originalBitmap = context.contentResolver.openInputStream(imageUri)?.use { input -> - BitmapFactory.decodeStream(input) - } ?: return null + val originalBitmap = + context.contentResolver.openInputStream(imageUri)?.use { input -> + BitmapFactory.decodeStream(input) + } + ?: return null val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) val compressedBitmap = compressImage(orientedBitmap) - // Generate unique filename - val filename = "${UUID.randomUUID()}.jpg" + // Generate filename using naming convention if problem info provided + val filename = + if (problemId != null && imageIndex != null) { + ImageNamingUtils.generateImageFilename(problemId, imageIndex) + } else { + "${UUID.randomUUID()}.jpg" + } val imageFile = File(getImagesDirectory(context), filename) // Save compressed image @@ -66,20 +78,19 @@ object ImageUtils { null } } - - /** - * Corrects image orientation based on EXIF data - */ + + /** Corrects image orientation based on EXIF data */ private fun correctImageOrientation(context: Context, imageUri: Uri, bitmap: Bitmap): Bitmap { return try { val inputStream = context.contentResolver.openInputStream(imageUri) inputStream?.use { input -> val exif = android.media.ExifInterface(input) - val orientation = exif.getAttributeInt( - android.media.ExifInterface.TAG_ORIENTATION, - android.media.ExifInterface.ORIENTATION_NORMAL - ) - + val orientation = + exif.getAttributeInt( + android.media.ExifInterface.TAG_ORIENTATION, + android.media.ExifInterface.ORIENTATION_NORMAL + ) + val matrix = android.graphics.Matrix() when (orientation) { android.media.ExifInterface.ORIENTATION_ROTATE_90 -> { @@ -106,36 +117,42 @@ object ImageUtils { matrix.postScale(-1f, 1f) } } - + if (matrix.isIdentity) { bitmap } else { android.graphics.Bitmap.createBitmap( - bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true ) } - } ?: bitmap + } + ?: bitmap } catch (e: Exception) { e.printStackTrace() bitmap } } - - /** - * Compresses and resizes an image bitmap - */ + + /** Compresses and resizes an image bitmap */ @SuppressLint("UseKtx") private fun compressImage(original: Bitmap): Bitmap { val width = original.width val height = original.height - + // Calculate the scaling factor - val scaleFactor = if (width > height) { - if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f - } else { - if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f - } - + val scaleFactor = + if (width > height) { + if (width > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / width else 1f + } else { + if (height > MAX_IMAGE_SIZE) MAX_IMAGE_SIZE.toFloat() / height else 1f + } + return if (scaleFactor < 1f) { val newWidth = (width * scaleFactor).toInt() val newHeight = (height * scaleFactor).toInt() @@ -144,7 +161,7 @@ object ImageUtils { original } } - + /** * Gets the full file path for an image * @param context Android context @@ -152,9 +169,16 @@ object ImageUtils { * @return Full file path */ fun getImageFile(context: Context, relativePath: String): File { - return File(context.filesDir, relativePath) + // If relativePath already contains the directory, use it as-is + // Otherwise, assume it's just a filename and add the images directory + return if (relativePath.contains("/")) { + File(context.filesDir, relativePath) + } else { + // Just a filename - look in the images directory + File(getImagesDirectory(context), relativePath) + } } - + /** * Deletes an image file * @param context Android context @@ -180,12 +204,12 @@ object ImageUtils { fun importImageFile(context: Context, sourceFile: File): String? { return try { if (!sourceFile.exists()) return null - + // Generate new filename to avoid conflicts val extension = sourceFile.extension.ifEmpty { "jpg" } val filename = "${UUID.randomUUID()}.$extension" val destFile = File(getImagesDirectory(context), filename) - + sourceFile.copyTo(destFile, overwrite = true) "$IMAGES_DIR/$filename" } catch (e: Exception) { @@ -193,7 +217,7 @@ object ImageUtils { null } } - + /** * Gets all image files in the images directory * @param context Android context @@ -203,16 +227,148 @@ object ImageUtils { return try { val imagesDir = getImagesDirectory(context) imagesDir.listFiles()?.mapNotNull { file -> - if (file.isFile && (file.extension == "jpg" || file.extension == "jpeg" || file.extension == "png")) { + if (file.isFile && + (file.extension == "jpg" || + file.extension == "jpeg" || + file.extension == "png") + ) { "$IMAGES_DIR/${file.name}" } else null - } ?: emptyList() + } + ?: emptyList() } catch (e: Exception) { e.printStackTrace() emptyList() } } - + + /** + * Saves an image from byte array to app's private storage + * @param context Android context + * @param imageData Byte array of the image data + * @return The relative file path if successful, null otherwise + */ + fun saveImageFromBytes(context: Context, imageData: ByteArray): String? { + return try { + val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null + + val compressedBitmap = compressImage(bitmap) + + // Generate unique filename + val filename = "${UUID.randomUUID()}.jpg" + val imageFile = File(getImagesDirectory(context), filename) + + // Save compressed image + FileOutputStream(imageFile).use { output -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) + } + + // Clean up bitmaps + bitmap.recycle() + compressedBitmap.recycle() + + // Return relative path + "$IMAGES_DIR/$filename" + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Saves image data with a specific filename (used for sync to preserve server filenames) + * @param context Android context + * @param imageData The image data as byte array + * @param filename The specific filename to use (including extension) + * @return The relative file path if successful, null otherwise + */ + fun saveImageFromBytesWithFilename( + context: Context, + imageData: ByteArray, + filename: String + ): String? { + return try { + val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null + + val compressedBitmap = compressImage(bitmap) + + // Use the provided filename instead of generating a new UUID + val imageFile = File(getImagesDirectory(context), filename) + + // Save compressed image + FileOutputStream(imageFile).use { output -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) + } + + // Clean up bitmaps + bitmap.recycle() + compressedBitmap.recycle() + + // Return relative path + "$IMAGES_DIR/$filename" + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Migrates existing images to use consistent naming convention + * @param context Android context + * @param problemId The problem ID these images belong to + * @param currentImagePaths List of current image paths for this problem + * @return Map of old path -> new path for successfully migrated images + */ + fun migrateImageNaming( + context: Context, + problemId: String, + currentImagePaths: List + ): Map { + val migrationMap = mutableMapOf() + + currentImagePaths.forEachIndexed { index, oldPath -> + val oldFilename = oldPath.substringAfterLast('/') + val newFilename = ImageNamingUtils.migrateFilename(oldFilename, problemId, index) + + if (oldFilename != newFilename) { + try { + val oldFile = getImageFile(context, oldPath) + val newFile = File(getImagesDirectory(context), newFilename) + + if (oldFile.exists() && oldFile.renameTo(newFile)) { + val newPath = "$IMAGES_DIR/$newFilename" + migrationMap[oldPath] = newPath + } + } catch (e: Exception) { + // Log error but continue with other images + e.printStackTrace() + } + } + } + + return migrationMap + } + + /** + * Batch migrates all images in the system to use consistent naming + * @param context Android context + * @param problemImageMap Map of problem ID -> list of current image paths + * @return Map of old path -> new path for all migrated images + */ + fun batchMigrateAllImages( + context: Context, + problemImageMap: Map> + ): Map { + val allMigrations = mutableMapOf() + + problemImageMap.forEach { (problemId, imagePaths) -> + val migrations = migrateImageNaming(context, problemId, imagePaths) + allMigrations.putAll(migrations) + } + + return allMigrations + } + /** * Cleans up orphaned images that are not referenced by any problems * @param context Android context @@ -222,10 +378,8 @@ object ImageUtils { try { val allImages = getAllImages(context) val orphanedImages = allImages.filter { it !in referencedPaths } - - orphanedImages.forEach { path -> - deleteImage(context, path) - } + + orphanedImages.forEach { path -> deleteImage(context, path) } } catch (e: Exception) { e.printStackTrace() } diff --git a/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt new file mode 100644 index 0000000..fd687c9 --- /dev/null +++ b/android/app/src/test/java/com/atridad/openclimb/SyncMergeLogicTest.kt @@ -0,0 +1,451 @@ +package com.atridad.openclimb + +import com.atridad.openclimb.data.format.* +import com.atridad.openclimb.data.model.* +import org.junit.Assert.* +import org.junit.Test + +class SyncMergeLogicTest { + + @Test + fun `test intelligent merge preserves all data`() { + // Create local data + val localGyms = + listOf( + BackupGym( + id = "gym1", + name = "Local Gym 1", + location = "Local Location", + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = listOf(DifficultySystem.V_SCALE), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localProblems = + listOf( + BackupProblem( + id = "problem1", + gymId = "gym1", + name = "Local Problem", + description = "Local description", + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"), + tags = listOf("local"), + location = null, + imagePaths = listOf("local_image.jpg"), + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localSessions = + listOf( + BackupClimbSession( + id = "session1", + gymId = "gym1", + date = "2024-01-01", + startTime = "2024-01-01T10:00:00", + endTime = "2024-01-01T12:00:00", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ) + + val localAttempts = + listOf( + BackupAttempt( + id = "attempt1", + sessionId = "session1", + problemId = "problem1", + result = AttemptResult.COMPLETED, + highestHold = null, + notes = null, + duration = 300, + restTime = null, + timestamp = "2024-01-01T10:30:00", + createdAt = "2024-01-01T10:30:00" + ) + ) + + val localBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = localGyms, + problems = localProblems, + sessions = localSessions, + attempts = localAttempts + ) + + // Create server data with some overlapping and some unique data + val serverGyms = + listOf( + // Same gym but with newer update + BackupGym( + id = "gym1", + name = "Updated Gym 1", + location = "Updated Location", + supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT), + difficultySystems = + listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = "Updated notes", + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T12:00:00" // Newer update + ), + // Unique server gym + BackupGym( + id = "gym2", + name = "Server Gym 2", + location = "Server Location", + supportedClimbTypes = listOf(ClimbType.TRAD), + difficultySystems = listOf(DifficultySystem.YDS), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T11:00:00", + updatedAt = "2024-01-01T11:00:00" + ) + ) + + val serverProblems = + listOf( + // Same problem but with newer update and different images + BackupProblem( + id = "problem1", + gymId = "gym1", + name = "Updated Problem", + description = "Updated description", + climbType = ClimbType.BOULDER, + difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"), + tags = listOf("updated", "server"), + location = "Updated location", + imagePaths = listOf("server_image.jpg"), + isActive = true, + dateSet = "2024-01-01", + notes = "Updated notes", + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T11:00:00" // Newer update + ), + // Unique server problem + BackupProblem( + id = "problem2", + gymId = "gym2", + name = "Server Problem", + description = "Server description", + climbType = ClimbType.TRAD, + difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"), + tags = listOf("server"), + location = null, + imagePaths = null, + isActive = true, + dateSet = null, + notes = null, + createdAt = "2024-01-01T11:00:00", + updatedAt = "2024-01-01T11:00:00" + ) + ) + + val serverSessions = + listOf( + // Unique server session + BackupClimbSession( + id = "session2", + gymId = "gym2", + date = "2024-01-02", + startTime = "2024-01-02T14:00:00", + endTime = "2024-01-02T16:00:00", + duration = 7200, + status = SessionStatus.COMPLETED, + notes = "Server session", + createdAt = "2024-01-02T14:00:00", + updatedAt = "2024-01-02T14:00:00" + ) + ) + + val serverAttempts = + listOf( + // Unique server attempt + BackupAttempt( + id = "attempt2", + sessionId = "session2", + problemId = "problem2", + result = AttemptResult.FELL, + highestHold = "Last move", + notes = "Almost had it", + duration = 180, + restTime = 60, + timestamp = "2024-01-02T14:30:00", + createdAt = "2024-01-02T14:30:00" + ) + ) + + val serverBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T12:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = serverGyms, + problems = serverProblems, + sessions = serverSessions, + attempts = serverAttempts + ) + + // Simulate merge logic + val mergedBackup = performIntelligentMerge(localBackup, serverBackup) + + // Verify merge results + assertEquals("Should have 2 gyms (1 updated, 1 new)", 2, mergedBackup.gyms.size) + assertEquals("Should have 2 problems (1 updated, 1 new)", 2, mergedBackup.problems.size) + assertEquals("Should have 2 sessions (1 local, 1 server)", 2, mergedBackup.sessions.size) + assertEquals("Should have 2 attempts (1 local, 1 server)", 2, mergedBackup.attempts.size) + + // Verify gym merge - server version should win (newer update) + val mergedGym1 = mergedBackup.gyms.find { it.id == "gym1" }!! + assertEquals("Updated Gym 1", mergedGym1.name) + assertEquals("Updated Location", mergedGym1.location) + assertEquals("Updated notes", mergedGym1.notes) + assertEquals("2024-01-01T12:00:00", mergedGym1.updatedAt) + + // Verify unique server gym is preserved + val mergedGym2 = mergedBackup.gyms.find { it.id == "gym2" }!! + assertEquals("Server Gym 2", mergedGym2.name) + + // Verify problem merge - server version should win but images should be merged + val mergedProblem1 = mergedBackup.problems.find { it.id == "problem1" }!! + assertEquals("Updated Problem", mergedProblem1.name) + assertEquals("Updated description", mergedProblem1.description) + assertEquals("2024-01-01T11:00:00", mergedProblem1.updatedAt) + + // Images should be merged (both local and server images preserved) + assertTrue( + "Should contain local image", + mergedProblem1.imagePaths!!.contains("local_image.jpg") + ) + assertTrue( + "Should contain server image", + mergedProblem1.imagePaths!!.contains("server_image.jpg") + ) + assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size) + + // Verify unique server problem is preserved + val mergedProblem2 = mergedBackup.problems.find { it.id == "problem2" }!! + assertEquals("Server Problem", mergedProblem2.name) + + // Verify all sessions are preserved + assertTrue( + "Should contain local session", + mergedBackup.sessions.any { it.id == "session1" } + ) + assertTrue( + "Should contain server session", + mergedBackup.sessions.any { it.id == "session2" } + ) + + // Verify all attempts are preserved + assertTrue( + "Should contain local attempt", + mergedBackup.attempts.any { it.id == "attempt1" } + ) + assertTrue( + "Should contain server attempt", + mergedBackup.attempts.any { it.id == "attempt2" } + ) + } + + @Test + fun `test date comparison logic`() { + assertTrue( + "ISO instant should be newer", + isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z") + ) + assertFalse( + "ISO instant should be older", + isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z") + ) + assertTrue( + "String comparison should work as fallback", + isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00") + ) + } + + @Test + fun `test empty data scenarios`() { + val emptyBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = emptyList(), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() + ) + + val dataBackup = + ClimbDataBackup( + exportedAt = "2024-01-01T10:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = + listOf( + BackupGym( + id = "gym1", + name = "Test Gym", + location = null, + supportedClimbTypes = listOf(ClimbType.BOULDER), + difficultySystems = + listOf(DifficultySystem.V_SCALE), + customDifficultyGrades = emptyList(), + notes = null, + createdAt = "2024-01-01T10:00:00", + updatedAt = "2024-01-01T10:00:00" + ) + ), + problems = emptyList(), + sessions = emptyList(), + attempts = emptyList() + ) + + // Test merging empty with data + val merged1 = performIntelligentMerge(emptyBackup, dataBackup) + assertEquals("Should preserve data from non-empty backup", 1, merged1.gyms.size) + + // Test merging data with empty + val merged2 = performIntelligentMerge(dataBackup, emptyBackup) + assertEquals("Should preserve data from non-empty backup", 1, merged2.gyms.size) + + // Test merging empty with empty + val merged3 = performIntelligentMerge(emptyBackup, emptyBackup) + assertEquals("Should remain empty", 0, merged3.gyms.size) + } + + // Helper methods that simulate the merge logic from SyncService + private fun performIntelligentMerge( + local: ClimbDataBackup, + server: ClimbDataBackup + ): ClimbDataBackup { + val mergedGyms = mergeGyms(local.gyms, server.gyms) + val mergedProblems = mergeProblems(local.problems, server.problems) + val mergedSessions = mergeSessions(local.sessions, server.sessions) + val mergedAttempts = mergeAttempts(local.attempts, server.attempts) + + return ClimbDataBackup( + exportedAt = "2024-01-01T12:00:00", + version = "2.0", + formatVersion = "2.0", + gyms = mergedGyms, + problems = mergedProblems, + sessions = mergedSessions, + attempts = mergedAttempts + ) + } + + private fun mergeGyms(local: List, server: List): List { + val merged = mutableMapOf() + + // Add all local gyms + local.forEach { gym -> merged[gym.id] = gym } + + // Add server gyms, preferring newer updates + server.forEach { serverGym -> + val localGym = merged[serverGym.id] + if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) { + merged[serverGym.id] = serverGym + } + } + + return merged.values.toList() + } + + private fun mergeProblems( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local problems + local.forEach { problem -> merged[problem.id] = problem } + + // Add server problems, preferring newer updates + server.forEach { serverProblem -> + val localProblem = merged[serverProblem.id] + if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ) { + // Merge image paths to preserve all images + val allImagePaths = mutableSetOf() + localProblem?.imagePaths?.let { allImagePaths.addAll(it) } + serverProblem.imagePaths?.let { allImagePaths.addAll(it) } + + merged[serverProblem.id] = + serverProblem.withUpdatedImagePaths(allImagePaths.toList()) + } + } + + return merged.values.toList() + } + + private fun mergeSessions( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local sessions + local.forEach { session -> merged[session.id] = session } + + // Add server sessions, preferring newer updates + server.forEach { serverSession -> + val localSession = merged[serverSession.id] + if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt) + ) { + merged[serverSession.id] = serverSession + } + } + + return merged.values.toList() + } + + private fun mergeAttempts( + local: List, + server: List + ): List { + val merged = mutableMapOf() + + // Add all local attempts + local.forEach { attempt -> merged[attempt.id] = attempt } + + // Add server attempts, preferring newer updates + server.forEach { serverAttempt -> + val localAttempt = merged[serverAttempt.id] + if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) + ) { + merged[serverAttempt.id] = serverAttempt + } + } + + return merged.values.toList() + } + + private fun isNewerThan(dateString1: String, dateString2: String): Boolean { + return try { + // Try parsing as instant first + val date1 = java.time.Instant.parse(dateString1) + val date2 = java.time.Instant.parse(dateString2) + date1.isAfter(date2) + } catch (e: Exception) { + // Fallback to string comparison + dateString1 > dateString2 + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 38d2547..8b50bce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -19,6 +19,7 @@ kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.10.2" coil = "2.7.0" ksp = "2.2.10-2.0.2" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -65,6 +66,9 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } # Image Loading coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +# HTTP Client +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + [plugins] diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..178a980 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.jar @@ -0,0 +1,16 @@ + +404 Not Found + +

404 Not Found

+
nginx
+ + + + + + + + + + + diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 2f1b85e4b9d66a00a2d408c8053a913a3f8012f7..de72ced2219c65201568ca9c1dc04cd8862ff5b3 100644 GIT binary patch literal 124304 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 Xesh5z@j|NlRS8eRE6U59(z diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme index e69de29..60b8335 100644 --- a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme +++ b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index ea9c62f..9ced8fa 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -57,6 +57,8 @@ struct ContentView: View { } .onAppear { setupNotificationObservers() + // Trigger auto-sync on app launch + dataManager.syncService.triggerAutoSync(dataManager: dataManager) } .onDisappear { removeNotificationObservers() @@ -101,6 +103,8 @@ struct ContentView: View { Task { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds await dataManager.onAppBecomeActive() + // Trigger auto-sync when app becomes active + dataManager.syncService.triggerAutoSync(dataManager: dataManager) } } diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift index 1a0f95a..a0dcb3c 100644 --- a/ios/OpenClimb/Models/BackupFormat.swift +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -1,10 +1,5 @@ // // BackupFormat.swift -// OpenClimb -// -// Created by OpenClimb Team on 2024-12-19. -// Copyright © 2024 OpenClimb. All rights reserved. -// import Foundation diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift new file mode 100644 index 0000000..61e4635 --- /dev/null +++ b/ios/OpenClimb/Services/SyncService.swift @@ -0,0 +1,978 @@ +import Combine +import Foundation +import UIKit + +@MainActor +class SyncService: ObservableObject { + + @Published var isSyncing = false + @Published var lastSyncTime: Date? + @Published var syncError: String? + @Published var isConnected = false + @Published var isTesting = false + + private let userDefaults = UserDefaults.standard + + private enum Keys { + static let serverURL = "sync_server_url" + static let authToken = "sync_auth_token" + static let lastSyncTime = "last_sync_time" + static let isConnected = "sync_is_connected" + static let autoSyncEnabled = "auto_sync_enabled" + } + + var serverURL: String { + get { userDefaults.string(forKey: Keys.serverURL) ?? "" } + set { userDefaults.set(newValue, forKey: Keys.serverURL) } + } + + var authToken: String { + get { userDefaults.string(forKey: Keys.authToken) ?? "" } + set { userDefaults.set(newValue, forKey: Keys.authToken) } + } + + var isConfigured: Bool { + return !serverURL.isEmpty && !authToken.isEmpty + } + + var isAutoSyncEnabled: Bool { + get { userDefaults.bool(forKey: Keys.autoSyncEnabled) } + set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) } + } + + init() { + if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { + self.lastSyncTime = lastSync + } + self.isConnected = userDefaults.bool(forKey: Keys.isConnected) + } + + func downloadData() async throws -> ClimbDataBackup { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/sync") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + default: + throw SyncError.serverError(httpResponse.statusCode) + } + + do { + let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return backup + } catch { + throw SyncError.decodingError(error) + } + } + + func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/sync") else { + throw SyncError.invalidURL + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let jsonData = try encoder.encode(backup) + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + case 400: + throw SyncError.badRequest + default: + throw SyncError.serverError(httpResponse.statusCode) + } + + do { + let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return responseBackup + } catch { + throw SyncError.decodingError(error) + } + } + + func uploadImage(filename: String, imageData: Data) async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw SyncError.unauthorized + default: + throw SyncError.serverError(httpResponse.statusCode) + } + } + + func downloadImage(filename: String) async throws -> Data { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + + throw SyncError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + + return data + case 401: + + throw SyncError.unauthorized + case 404: + + throw SyncError.imageNotFound + default: + + throw SyncError.serverError(httpResponse.statusCode) + } + } + + func syncWithServer(dataManager: ClimbingDataManager) async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + guard isConnected else { + throw SyncError.notConnected + } + + isSyncing = true + syncError = nil + + defer { + isSyncing = false + } + + do { + // 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 + + let hasServerData = + !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty + || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty + + if !hasLocalData && hasServerData { + // Case 1: No local data - do full restore from server + print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server") + print("Syncing images from server first...") + let imagePathMapping = try await syncImagesFromServer( + backup: serverBackup, dataManager: dataManager) + print("Importing data after images...") + try importBackupToDataManager( + serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) + print("Full restore completed") + } else if hasLocalData && !hasServerData { + // Case 2: No server data - upload local data to server + print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server") + let currentBackup = createBackupFromDataManager(dataManager) + _ = try await uploadData(currentBackup) + print("Uploading local images to server...") + try await syncImagesToServer(dataManager: dataManager) + print("Initial upload completed") + } else if hasLocalData && hasServerData { + // Case 3: Both have data - compare timestamps (last writer wins) + let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt) + let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt) + + print("🕐 DEBUG iOS Timestamp Comparison:") + print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)") + print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)") + print( + " DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'" + ) + print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)") + + if localTimestamp > serverTimestamp { + // Local is newer - replace server with local data + print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content") + let currentBackup = createBackupFromDataManager(dataManager) + _ = try await uploadData(currentBackup) + try await syncImagesToServer(dataManager: dataManager) + print("Server replaced with local data") + } else if serverTimestamp > localTimestamp { + // Server is newer - replace local with server data + print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content") + let imagePathMapping = try await syncImagesFromServer( + backup: serverBackup, dataManager: dataManager) + try importBackupToDataManager( + serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) + print("Local data replaced with server data") + } else { + // Timestamps are equal - no sync needed + print( + "🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed" + ) + } + } else { + print("No data to sync") + } + + // Update last sync time + lastSyncTime = Date() + userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) + + } catch { + syncError = error.localizedDescription + throw error + } + } + + /// Parses ISO8601 timestamp to milliseconds for comparison + private func parseISO8601ToMillis(timestamp: String) -> Int64 { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: timestamp) { + return Int64(date.timeIntervalSince1970 * 1000) + } + print("Failed to parse timestamp: \(timestamp), using 0") + return 0 + } + + private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) + async throws -> [String: String] + { + var imagePathMapping: [String: String] = [:] + + // Process images by problem to maintain consistent naming + for problem in backup.problems { + guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } + + for (index, imagePath) in imagePaths.enumerated() { + let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent + + do { + let imageData = try await downloadImage(filename: serverFilename) + + // Generate consistent filename if needed + let consistentFilename = + ImageNamingUtils.isValidImageFilename(serverFilename) + ? serverFilename + : ImageNamingUtils.generateImageFilename( + problemId: problem.id, imageIndex: index) + + // Save image with consistent filename + let imageManager = ImageManager.shared + let localPath = try imageManager.saveImportedImage( + imageData, filename: consistentFilename) + + // Map server filename to consistent local filename + imagePathMapping[serverFilename] = consistentFilename + print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") + } catch SyncError.imageNotFound { + print("Image not found on server: \(serverFilename)") + continue + } catch { + print("Failed to download image \(serverFilename): \(error)") + continue + } + } + } + + return imagePathMapping + } + + private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { + // Process images by problem to ensure consistent naming + for problem in dataManager.problems { + guard !problem.imagePaths.isEmpty else { continue } + + for (index, imagePath) in problem.imagePaths.enumerated() { + let filename = URL(fileURLWithPath: imagePath).lastPathComponent + + // Ensure filename follows consistent naming convention + let consistentFilename = + ImageNamingUtils.isValidImageFilename(filename) + ? filename + : ImageNamingUtils.generateImageFilename( + problemId: problem.id.uuidString, imageIndex: index) + + // Load image data + let imageManager = ImageManager.shared + let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path + + if let imageData = imageManager.loadImageData(fromPath: fullPath) { + do { + // If filename changed, rename local file + if filename != consistentFilename { + let newPath = imageManager.imagesDirectory.appendingPathComponent( + consistentFilename + ).path + do { + try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) + print("Renamed local image: \(filename) -> \(consistentFilename)") + + // Update problem's image path in memory for consistency + // Note: This would require updating the problem in the data manager + } catch { + print("Failed to rename local image, using original: \(error)") + } + } + + try await uploadImage(filename: consistentFilename, imageData: imageData) + print("Successfully uploaded image: \(consistentFilename)") + } catch { + print("Failed to upload image \(consistentFilename): \(error)") + // Continue with other images even if one fails + } + } + } + } + } + + private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup + { + return ClimbDataBackup( + exportedAt: DataStateManager.shared.getLastModified(), + gyms: dataManager.gyms.map { BackupGym(from: $0) }, + problems: dataManager.problems.map { BackupProblem(from: $0) }, + sessions: dataManager.sessions.map { BackupClimbSession(from: $0) }, + attempts: dataManager.attempts.map { BackupAttempt(from: $0) } + ) + } + + private func importBackupToDataManager( + _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, + imagePathMapping: [String: String] = [:] + ) throws { + do { + + // Update problem image paths to point to downloaded images + let updatedBackup: ClimbDataBackup + if !imagePathMapping.isEmpty { + let updatedProblems = backup.problems.map { problem in + let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in + imagePathMapping[oldPath] ?? oldPath + } + return BackupProblem( + id: problem.id, + gymId: problem.gymId, + name: problem.name, + description: problem.description, + climbType: problem.climbType, + difficulty: problem.difficulty, + tags: problem.tags, + location: problem.location, + imagePaths: updatedImagePaths, + isActive: problem.isActive, + dateSet: problem.dateSet, + notes: problem.notes, + createdAt: problem.createdAt, + updatedAt: problem.updatedAt + ) + } + updatedBackup = ClimbDataBackup( + exportedAt: backup.exportedAt, + version: backup.version, + formatVersion: backup.formatVersion, + gyms: backup.gyms, + problems: updatedProblems, + sessions: backup.sessions, + attempts: backup.attempts + ) + + } else { + updatedBackup = backup + } + + // Create a minimal ZIP with just the JSON data for existing import mechanism + let zipData = try createMinimalZipFromBackup(updatedBackup) + + // Use existing import method which properly handles data restoration + try dataManager.importData(from: zipData) + + // Update local data state to match imported data timestamp + DataStateManager.shared.setLastModified(backup.exportedAt) + print("Data state synchronized to imported timestamp: \(backup.exportedAt)") + + } catch { + + throw SyncError.importFailed(error) + } + } + + private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data { + // Create JSON data + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + encoder.dateEncodingStrategy = .custom { date, encoder in + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from: date)) + } + let jsonData = try encoder.encode(backup) + + // Collect all downloaded images from ImageManager + let imageManager = ImageManager.shared + var imageFiles: [(filename: String, data: Data)] = [] + let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] }) + + for imagePath in imagePaths { + let filename = URL(fileURLWithPath: imagePath).lastPathComponent + let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path + if let imageData = imageManager.loadImageData(fromPath: fullPath) { + imageFiles.append((filename: filename, data: imageData)) + + } + } + + // Create ZIP with data.json, metadata, and images + var zipData = Data() + var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] + var currentOffset: UInt32 = 0 + + // Add data.json to ZIP + try addFileToMinimalZip( + filename: "data.json", + fileData: jsonData, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + + // Add metadata with correct image count + let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)" + let metadataData = metadata.data(using: .utf8) ?? Data() + try addFileToMinimalZip( + filename: "metadata.txt", + fileData: metadataData, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + + // Add images to ZIP in images/ directory + for imageFile in imageFiles { + try addFileToMinimalZip( + filename: "images/\(imageFile.filename)", + fileData: imageFile.data, + zipData: &zipData, + fileEntries: &fileEntries, + currentOffset: ¤tOffset + ) + } + + // Add central directory + var centralDirectory = Data() + for entry in fileEntries { + centralDirectory.append(createCentralDirectoryHeader(entry: entry)) + } + + // Add end of central directory record + let endOfCentralDir = createEndOfCentralDirectoryRecord( + fileCount: UInt16(fileEntries.count), + centralDirSize: UInt32(centralDirectory.count), + centralDirOffset: currentOffset + ) + + zipData.append(centralDirectory) + zipData.append(endOfCentralDir) + + return zipData + } + + private func addFileToMinimalZip( + filename: String, + fileData: Data, + zipData: inout Data, + fileEntries: inout [(name: String, data: Data, offset: UInt32)], + currentOffset: inout UInt32 + ) throws { + let localFileHeader = createLocalFileHeader( + filename: filename, fileSize: UInt32(fileData.count)) + + fileEntries.append((name: filename, data: fileData, offset: currentOffset)) + + zipData.append(localFileHeader) + zipData.append(fileData) + + currentOffset += UInt32(localFileHeader.count + fileData.count) + } + + private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data { + var header = Data() + + // Local file header signature + header.append(Data([0x50, 0x4b, 0x03, 0x04])) + + // Version needed to extract (2.0) + header.append(Data([0x14, 0x00])) + + // General purpose bit flag + header.append(Data([0x00, 0x00])) + + // Compression method (no compression) + header.append(Data([0x00, 0x00])) + + // Last mod file time & date (dummy values) + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // CRC-32 (dummy - we're not compressing) + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Compressed size + withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } + + // Uncompressed size + withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } + + // File name length + let filenameData = filename.data(using: .utf8) ?? Data() + let filenameLength = UInt16(filenameData.count) + withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } + + // Extra field length + header.append(Data([0x00, 0x00])) + + // File name + header.append(filenameData) + + return header + } + + private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32)) + -> Data + { + var header = Data() + + // Central directory signature + header.append(Data([0x50, 0x4b, 0x01, 0x02])) + + // Version made by + header.append(Data([0x14, 0x00])) + + // Version needed to extract + header.append(Data([0x14, 0x00])) + + // General purpose bit flag + header.append(Data([0x00, 0x00])) + + // Compression method + header.append(Data([0x00, 0x00])) + + // Last mod file time & date + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // CRC-32 + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Compressed size + let compressedSize = UInt32(entry.data.count) + withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } + + // Uncompressed size + withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } + + // File name length + let filenameData = entry.name.data(using: .utf8) ?? Data() + let filenameLength = UInt16(filenameData.count) + withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } + + // Extra field length + header.append(Data([0x00, 0x00])) + + // File comment length + header.append(Data([0x00, 0x00])) + + // Disk number start + header.append(Data([0x00, 0x00])) + + // Internal file attributes + header.append(Data([0x00, 0x00])) + + // External file attributes + header.append(Data([0x00, 0x00, 0x00, 0x00])) + + // Relative offset of local header + withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) } + + // File name + header.append(filenameData) + + return header + } + + private func createEndOfCentralDirectoryRecord( + fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 + ) -> Data { + var record = Data() + + // End of central dir signature + record.append(Data([0x50, 0x4b, 0x05, 0x06])) + + // Number of this disk + record.append(Data([0x00, 0x00])) + + // Number of the disk with the start of the central directory + record.append(Data([0x00, 0x00])) + + // Total number of entries in the central directory on this disk + withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } + + // Total number of entries in the central directory + withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } + + // Size of the central directory + withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) } + + // Offset of start of central directory + withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) } + + // ZIP file comment length + record.append(Data([0x00, 0x00])) + + return record + } + + func testConnection() async throws { + guard isConfigured else { + throw SyncError.notConfigured + } + + isTesting = true + defer { isTesting = false } + + guard let url = URL(string: "\(serverURL)/health") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw SyncError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw SyncError.serverError(httpResponse.statusCode) + } + + // Connection successful, mark as connected + isConnected = true + userDefaults.set(true, forKey: Keys.isConnected) + } + + func triggerAutoSync(dataManager: ClimbingDataManager) { + guard isConnected && isConfigured && isAutoSyncEnabled else { return } + + Task { + do { + try await syncWithServer(dataManager: dataManager) + } catch { + print("Auto-sync failed: \(error)") + // Don't show UI errors for auto-sync failures + } + } + } + + // DEPRECATED: Complex merge logic replaced with simple timestamp-based sync + // These methods are no longer used but kept for reference + @available(*, deprecated, message: "Use simple timestamp-based sync instead") + private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws + -> ClimbDataBackup + { + print("Merging data - preserving all entities to prevent data loss") + + // Merge gyms by ID, keeping most recently updated + let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms) + + // Merge problems by ID, keeping most recently updated + let mergedProblems = mergeProblems(local: local.problems, server: server.problems) + + // Merge sessions by ID, keeping most recently updated + let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions) + + // Merge attempts by ID, keeping most recently updated + let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts) + + print( + "Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)" + ) + + return ClimbDataBackup( + exportedAt: ISO8601DateFormatter().string(from: Date()), + version: "2.0", + formatVersion: "2.0", + gyms: mergedGyms, + problems: mergedProblems, + sessions: mergedSessions, + attempts: mergedAttempts + ) + } + + private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] { + var merged: [String: BackupGym] = [:] + + // Add all local gyms + for gym in local { + merged[gym.id] = gym + } + + // Add server gyms, replacing if newer + for serverGym in server { + if let localGym = merged[serverGym.id] { + // Keep the most recently updated + if isNewerThan(serverGym.updatedAt, localGym.updatedAt) { + merged[serverGym.id] = serverGym + } + } else { + // New gym from server + merged[serverGym.id] = serverGym + } + } + + return Array(merged.values) + } + + private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] { + var merged: [String: BackupProblem] = [:] + + // Add all local problems + for problem in local { + merged[problem.id] = problem + } + + // Add server problems, replacing if newer or merging image paths + for serverProblem in server { + if let localProblem = merged[serverProblem.id] { + // Merge image paths from both sources + let localImages = Set(localProblem.imagePaths ?? []) + let serverImages = Set(serverProblem.imagePaths ?? []) + let mergedImages = Array(localImages.union(serverImages)) + + // Use most recently updated problem data but with merged images + let newerProblem = + isNewerThan(serverProblem.updatedAt, localProblem.updatedAt) + ? serverProblem : localProblem + merged[serverProblem.id] = BackupProblem( + id: newerProblem.id, + gymId: newerProblem.gymId, + name: newerProblem.name, + description: newerProblem.description, + climbType: newerProblem.climbType, + difficulty: newerProblem.difficulty, + tags: newerProblem.tags, + location: newerProblem.location, + imagePaths: mergedImages.isEmpty ? nil : mergedImages, + isActive: newerProblem.isActive, + dateSet: newerProblem.dateSet, + notes: newerProblem.notes, + createdAt: newerProblem.createdAt, + updatedAt: newerProblem.updatedAt + ) + } else { + // New problem from server + merged[serverProblem.id] = serverProblem + } + } + + return Array(merged.values) + } + + private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession]) + -> [BackupClimbSession] + { + var merged: [String: BackupClimbSession] = [:] + + // Add all local sessions + for session in local { + merged[session.id] = session + } + + // Add server sessions, replacing if newer + for serverSession in server { + if let localSession = merged[serverSession.id] { + // Keep the most recently updated + if isNewerThan(serverSession.updatedAt, localSession.updatedAt) { + merged[serverSession.id] = serverSession + } + } else { + // New session from server + merged[serverSession.id] = serverSession + } + } + + return Array(merged.values) + } + + private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] { + var merged: [String: BackupAttempt] = [:] + + // Add all local attempts + for attempt in local { + merged[attempt.id] = attempt + } + + // Add server attempts, replacing if newer + for serverAttempt in server { + if let localAttempt = merged[serverAttempt.id] { + // Keep the most recently created (attempts don't typically get updated) + if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) { + merged[serverAttempt.id] = serverAttempt + } + } else { + // New attempt from server + merged[serverAttempt.id] = serverAttempt + } + } + + return Array(merged.values) + } + + private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool { + let formatter = ISO8601DateFormatter() + guard let date1 = formatter.date(from: dateString1), + let date2 = formatter.date(from: dateString2) + else { + return false + } + return date1 > date2 + } + + func disconnect() { + isConnected = false + lastSyncTime = nil + syncError = nil + userDefaults.set(false, forKey: Keys.isConnected) + userDefaults.removeObject(forKey: Keys.lastSyncTime) + } + + func clearConfiguration() { + serverURL = "" + authToken = "" + lastSyncTime = nil + isConnected = false + isAutoSyncEnabled = true + userDefaults.removeObject(forKey: Keys.lastSyncTime) + userDefaults.removeObject(forKey: Keys.isConnected) + userDefaults.removeObject(forKey: Keys.autoSyncEnabled) + } +} + +// Removed SyncTrigger enum - now using simple auto sync on any data change + +enum SyncError: LocalizedError { + case notConfigured + case notConnected + case invalidURL + case invalidResponse + case unauthorized + case badRequest + case serverError(Int) + case decodingError(Error) + case exportFailed + case importFailed(Error) + case imageNotFound + case imageUploadFailed + + var errorDescription: String? { + switch self { + case .notConfigured: + return "Sync server not configured. Please set server URL and auth token." + case .notConnected: + return "Not connected to sync server. Please test connection first." + case .invalidURL: + return "Invalid server URL." + case .invalidResponse: + return "Invalid response from server." + case .unauthorized: + return "Authentication failed. Check your auth token." + case .badRequest: + return "Bad request. Check your data format." + case .serverError(let code): + return "Server error (code \(code))." + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .exportFailed: + return "Failed to export local data." + case .importFailed(let error): + return "Failed to import data: \(error.localizedDescription)" + case .imageNotFound: + return "Image not found on server." + case .imageUploadFailed: + return "Failed to upload image to server." + } + } +} diff --git a/ios/OpenClimb/Utils/DataStateManager.swift b/ios/OpenClimb/Utils/DataStateManager.swift new file mode 100644 index 0000000..d533284 --- /dev/null +++ b/ios/OpenClimb/Utils/DataStateManager.swift @@ -0,0 +1,85 @@ +// +// DataStateManager.swift + +import Foundation + +/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the +/// local database was last modified, independent of individual entity timestamps. +class DataStateManager { + + private let userDefaults = UserDefaults.standard + + private enum Keys { + static let lastModified = "openclimb_data_last_modified" + static let initialized = "openclimb_data_state_initialized" + } + + /// Shared instance for app-wide use + static let shared = DataStateManager() + + private init() { + // Initialize with current timestamp if this is the first time + if !isInitialized() { + print("DataStateManager: First time initialization") + // Set initial timestamp to a very old date so server data will be considered newer + let epochTime = "1970-01-01T00:00:00.000Z" + userDefaults.set(epochTime, forKey: Keys.lastModified) + markAsInitialized() + print("DataStateManager initialized with epoch timestamp: \(epochTime)") + } else { + print("DataStateManager: Already initialized, current timestamp: \(getLastModified())") + } + } + + /// Updates the data state timestamp to the current time. Call this whenever any data is modified + /// (create, update, delete). + func updateDataState() { + let now = ISO8601DateFormatter().string(from: Date()) + userDefaults.set(now, forKey: Keys.lastModified) + print("📝 iOS Data state updated to: \(now)") + } + + /// Gets the current data state timestamp. This represents when any data was last modified + /// locally. + func getLastModified() -> String { + if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) { + print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)") + return storedTimestamp + } + + // If no timestamp is stored, return epoch time to indicate very old data + // This ensures server data will be considered newer than uninitialized local data + let epochTime = "1970-01-01T00:00:00.000Z" + print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)") + return epochTime + } + + /// Sets the data state timestamp to a specific value. Used when importing data from server to + /// sync the state. + func setLastModified(_ timestamp: String) { + userDefaults.set(timestamp, forKey: Keys.lastModified) + print("Data state set to: \(timestamp)") + } + + /// Resets the data state (for testing or complete data wipe). + func reset() { + userDefaults.removeObject(forKey: Keys.lastModified) + userDefaults.removeObject(forKey: Keys.initialized) + print("Data state reset") + } + + /// Checks if the data state has been initialized. + private func isInitialized() -> Bool { + return userDefaults.bool(forKey: Keys.initialized) + } + + /// Marks the data state as initialized. + private func markAsInitialized() { + userDefaults.set(true, forKey: Keys.initialized) + } + + /// Gets debug information about the current state. + func getDebugInfo() -> String { + return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))" + } +} diff --git a/ios/OpenClimb/Utils/ImageNamingUtils.swift b/ios/OpenClimb/Utils/ImageNamingUtils.swift new file mode 100644 index 0000000..8aca2d1 --- /dev/null +++ b/ios/OpenClimb/Utils/ImageNamingUtils.swift @@ -0,0 +1,176 @@ +// +// ImageNamingUtils.swift + +import CryptoKit +import Foundation + +/// Utility for creating consistent image filenames across iOS and Android platforms. +/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility. +class ImageNamingUtils { + + private static let imageExtension = ".jpg" + private static let hashLength = 12 // First 12 chars of SHA-256 + + /// Generates a deterministic filename for a problem image. + /// Format: "problem_{hash}_{index}.jpg" + /// + /// - Parameters: + /// - problemId: The ID of the problem this image belongs to + /// - timestamp: ISO8601 timestamp when the image was created + /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) + /// - Returns: A consistent filename that will be the same across platforms + static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int) + -> String + { + // Create a deterministic hash from problemId + timestamp + index + let input = "\(problemId)_\(timestamp)_\(imageIndex)" + let hash = createHash(from: input) + + return "problem_\(hash)_\(imageIndex)\(imageExtension)" + } + + /// Generates a deterministic filename for a problem image using current timestamp. + /// + /// - Parameters: + /// - problemId: The ID of the problem this image belongs to + /// - imageIndex: The index of this image for the problem (0, 1, 2, etc.) + /// - Returns: A consistent filename + static func generateImageFilename(problemId: String, imageIndex: Int) -> String { + let timestamp = ISO8601DateFormatter().string(from: Date()) + return generateImageFilename( + problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) + } + + /// Extracts problem ID from an image filename created by this utility. + /// Returns nil if the filename doesn't match our naming convention. + /// + /// - Parameter filename: The image filename + /// - Returns: The hash identifier or nil if not a valid filename + static func extractProblemIdFromFilename(_ filename: String) -> String? { + guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { + return nil + } + + // Format: problem_{hash}_{index}.jpg + let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) + let parts = nameWithoutExtension.components(separatedBy: "_") + + guard parts.count == 3 && parts[0] == "problem" else { + return nil + } + + // Return the hash as identifier + return parts[1] + } + + /// Validates if a filename follows our naming convention. + /// + /// - Parameter filename: The filename to validate + /// - Returns: true if it matches our convention, false otherwise + static func isValidImageFilename(_ filename: String) -> Bool { + guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else { + return false + } + + let nameWithoutExtension = String(filename.dropLast(imageExtension.count)) + let parts = nameWithoutExtension.components(separatedBy: "_") + + return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength + && Int(parts[2]) != nil + } + + /// Migrates an existing UUID-based filename to our naming convention. + /// This is used during sync to rename downloaded images. + /// + /// - Parameters: + /// - oldFilename: The existing filename (UUID-based) + /// - problemId: The problem ID this image belongs to + /// - imageIndex: The index of this image + /// - Returns: The new filename following our convention + static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String { + // If it's already using our convention, keep it + if isValidImageFilename(oldFilename) { + return oldFilename + } + + // Generate new deterministic name + // Use current timestamp to maintain some consistency + let timestamp = ISO8601DateFormatter().string(from: Date()) + return generateImageFilename( + problemId: problemId, timestamp: timestamp, imageIndex: imageIndex) + } + + /// Creates a deterministic hash from input string. + /// Uses SHA-256 and takes first 12 characters for filename safety. + /// + /// - Parameter input: The input string to hash + /// - Returns: First 12 characters of SHA-256 hash in lowercase + private static func createHash(from input: String) -> String { + let inputData = Data(input.utf8) + let hashed = SHA256.hash(data: inputData) + let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined() + return String(hashString.prefix(hashLength)) + } + + /// Batch renames images for a problem to use our naming convention. + /// Returns a mapping of old filename -> new filename. + /// + /// - Parameters: + /// - problemId: The problem ID + /// - existingFilenames: List of current image filenames for this problem + /// - Returns: Dictionary mapping old filename to new filename + static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String: + String] + { + var renameMap: [String: String] = [:] + + for (index, oldFilename) in existingFilenames.enumerated() { + let newFilename = migrateFilename( + oldFilename: oldFilename, problemId: problemId, imageIndex: index) + if newFilename != oldFilename { + renameMap[oldFilename] = newFilename + } + } + + return renameMap + } + + /// Validates that a collection of filenames follow our naming convention. + /// + /// - Parameter filenames: Array of filenames to validate + /// - Returns: Dictionary with validation results + static func validateFilenames(_ filenames: [String]) -> ImageValidationResult { + var validImages: [String] = [] + var invalidImages: [String] = [] + + for filename in filenames { + if isValidImageFilename(filename) { + validImages.append(filename) + } else { + invalidImages.append(filename) + } + } + + return ImageValidationResult( + totalImages: filenames.count, + validImages: validImages, + invalidImages: invalidImages + ) + } +} + +/// Result of image filename validation +struct ImageValidationResult { + let totalImages: Int + let validImages: [String] + let invalidImages: [String] + + var isAllValid: Bool { + return invalidImages.isEmpty + } + + var validPercentage: Double { + guard totalImages > 0 else { return 100.0 } + return (Double(validImages.count) / Double(totalImages)) * 100.0 + } +} diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 24719ec..3cad61f 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -29,6 +29,9 @@ class ClimbingDataManager: ObservableObject { private let decoder = JSONDecoder() private var liveActivityObserver: NSObjectProtocol? + // Sync service for automatic syncing + let syncService = SyncService() + private enum Keys { static let gyms = "openclimb_gyms" static let problems = "openclimb_problems" @@ -200,6 +203,7 @@ class ClimbingDataManager: ObservableObject { func addGym(_ gym: Gym) { gyms.append(gym) saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym added successfully" clearMessageAfterDelay() } @@ -208,6 +212,7 @@ class ClimbingDataManager: ObservableObject { if let index = gyms.firstIndex(where: { $0.id == gym.id }) { gyms[index] = gym saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym updated successfully" clearMessageAfterDelay() } @@ -229,6 +234,7 @@ class ClimbingDataManager: ObservableObject { // Delete the gym gyms.removeAll { $0.id == gym.id } saveGyms() + DataStateManager.shared.updateDataState() successMessage = "Gym deleted successfully" clearMessageAfterDelay() } @@ -240,14 +246,19 @@ class ClimbingDataManager: ObservableObject { func addProblem(_ problem: Problem) { problems.append(problem) saveProblems() + DataStateManager.shared.updateDataState() successMessage = "Problem added successfully" clearMessageAfterDelay() + + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) } func updateProblem(_ problem: Problem) { if let index = problems.firstIndex(where: { $0.id == problem.id }) { problems[index] = problem saveProblems() + DataStateManager.shared.updateDataState() successMessage = "Problem updated successfully" clearMessageAfterDelay() } @@ -264,6 +275,7 @@ class ClimbingDataManager: ObservableObject { // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() + DataStateManager.shared.updateDataState() } func problem(withId id: UUID) -> Problem? { @@ -290,6 +302,7 @@ class ClimbingDataManager: ObservableObject { saveActiveSession() saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session started successfully" clearMessageAfterDelay() @@ -317,9 +330,13 @@ class ClimbingDataManager: ObservableObject { saveActiveSession() saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session completed successfully" clearMessageAfterDelay() + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) + // MARK: - End Live Activity after session ends Task { await LiveActivityManager.shared.endLiveActivity() @@ -337,6 +354,7 @@ class ClimbingDataManager: ObservableObject { } saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session updated successfully" clearMessageAfterDelay() @@ -359,6 +377,7 @@ class ClimbingDataManager: ObservableObject { // Delete the session sessions.removeAll { $0.id == session.id } saveSessions() + DataStateManager.shared.updateDataState() successMessage = "Session deleted successfully" clearMessageAfterDelay() } @@ -380,8 +399,12 @@ class ClimbingDataManager: ObservableObject { func addAttempt(_ attempt: Attempt) { attempts.append(attempt) saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt logged successfully" + + // Trigger auto-sync if enabled + syncService.triggerAutoSync(dataManager: self) clearMessageAfterDelay() // Update Live Activity when new attempt is added @@ -392,6 +415,7 @@ class ClimbingDataManager: ObservableObject { if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index] = attempt saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt updated successfully" clearMessageAfterDelay() @@ -403,6 +427,7 @@ class ClimbingDataManager: ObservableObject { func deleteAttempt(_ attempt: Attempt) { attempts.removeAll { $0.id == attempt.id } saveAttempts() + DataStateManager.shared.updateDataState() successMessage = "Attempt deleted successfully" clearMessageAfterDelay() @@ -464,6 +489,7 @@ class ClimbingDataManager: ObservableObject { userDefaults.removeObject(forKey: Keys.attempts) userDefaults.removeObject(forKey: Keys.activeSession) + DataStateManager.shared.reset() successMessage = "All data has been reset" clearMessageAfterDelay() } @@ -557,6 +583,9 @@ class ClimbingDataManager: ObservableObject { saveSessions() saveAttempts() + // Update data state to current time since we just imported new data + DataStateManager.shared.updateDataState() + successMessage = "Data imported successfully with \(importResult.imagePathMapping.count) images" clearMessageAfterDelay() diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 79a832e..56a2694 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -12,6 +12,9 @@ struct SettingsView: View { var body: some View { List { + SyncSection() + .environmentObject(dataManager.syncService) + DataManagementSection( activeSheet: $activeSheet ) @@ -303,6 +306,361 @@ struct ExportDataView: View { } } +struct SyncSection: View { + @EnvironmentObject var syncService: SyncService + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingSyncSettings = false + @State private var showingDisconnectAlert = false + + var body: some View { + Section("Sync") { + // Sync Status + HStack { + Image( + systemName: syncService.isConnected + ? "checkmark.circle.fill" + : syncService.isConfigured + ? "exclamationmark.triangle.fill" + : "exclamationmark.circle.fill" + ) + .foregroundColor( + syncService.isConnected + ? .green + : syncService.isConfigured + ? .orange + : .red + ) + VStack(alignment: .leading) { + Text("Sync Server") + .font(.headline) + Text( + syncService.isConnected + ? "Connected" + : syncService.isConfigured + ? "Configured - Not tested" + : "Not configured" + ) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + + // Configure Server + Button(action: { + showingSyncSettings = true + }) { + HStack { + Image(systemName: "gear") + .foregroundColor(.blue) + Text("Configure Server") + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + + if syncService.isConfigured { + + // Sync Now - only show if connected + if syncService.isConnected { + Button(action: { + performSync() + }) { + HStack { + if syncService.isSyncing { + ProgressView() + .scaleEffect(0.8) + Text("Syncing...") + .foregroundColor(.secondary) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundColor(.green) + Text("Sync Now") + Spacer() + if let lastSync = syncService.lastSyncTime { + Text( + RelativeDateTimeFormatter().localizedString( + for: lastSync, relativeTo: Date()) + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .disabled(syncService.isSyncing) + .foregroundColor(.primary) + } + + // Auto-sync configuration - always visible for testing + HStack { + VStack(alignment: .leading) { + Text("Auto-sync") + Text("Sync automatically on app launch and data changes") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { syncService.isAutoSyncEnabled }, + set: { syncService.isAutoSyncEnabled = $0 } + ) + ) + .disabled(!syncService.isConnected) + } + .foregroundColor(.primary) + + // Disconnect option - only show if connected + if syncService.isConnected { + Button(action: { + showingDisconnectAlert = true + }) { + HStack { + Image(systemName: "power") + .foregroundColor(.orange) + Text("Disconnect") + Spacer() + } + } + .foregroundColor(.primary) + } + + if let error = syncService.syncError { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding(.leading, 24) + } + } + } + .sheet(isPresented: $showingSyncSettings) { + SyncSettingsView() + .environmentObject(syncService) + } + .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { + Button("Cancel", role: .cancel) {} + Button("Disconnect", role: .destructive) { + syncService.disconnect() + } + } message: { + Text( + "This will sign you out but keep your server settings. You'll need to test the connection again to sync." + ) + } + } + + private func performSync() { + Task { + do { + try await syncService.syncWithServer(dataManager: dataManager) + } catch { + print("Sync failed: \(error)") + } + } + } +} + +struct SyncSettingsView: View { + @EnvironmentObject var syncService: SyncService + @Environment(\.dismiss) private var dismiss + @State private var serverURL: String = "" + @State private var authToken: String = "" + @State private var showingDisconnectAlert = false + @State private var isTesting = false + @State private var showingTestResult = false + @State private var testResultMessage = "" + + var body: some View { + NavigationView { + Form { + Section { + TextField("Server URL", text: $serverURL) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .placeholder(when: serverURL.isEmpty) { + Text("http://your-server:8080") + .foregroundColor(.secondary) + } + + TextField("Auth Token", text: $authToken) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .placeholder(when: authToken.isEmpty) { + Text("your-secret-token") + .foregroundColor(.secondary) + } + } header: { + Text("Server Configuration") + } footer: { + Text( + "Enter your sync server URL and authentication token. You must test the connection before syncing is available." + ) + } + + Section { + Button(action: { + testConnection() + }) { + HStack { + if isTesting { + ProgressView() + .scaleEffect(0.8) + Text("Testing...") + .foregroundColor(.secondary) + } else { + Image(systemName: "network") + .foregroundColor(.blue) + Text("Test Connection") + Spacer() + if syncService.isConnected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } + } + } + } + .disabled( + isTesting + || serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + .foregroundColor(.primary) + } header: { + Text("Connection") + } footer: { + Text("Test the connection to verify your server settings before saving.") + } + + Section { + Button("Disconnect from Server") { + showingDisconnectAlert = true + } + .foregroundColor(.orange) + + Button("Clear Configuration") { + syncService.clearConfiguration() + serverURL = "" + authToken = "" + } + .foregroundColor(.red) + } footer: { + Text( + "Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings." + ) + } + } + .navigationTitle("Sync Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) + + // Mark as disconnected if settings changed + if newURL != syncService.serverURL || newToken != syncService.authToken { + syncService.isConnected = false + UserDefaults.standard.set(false, forKey: "sync_is_connected") + } + + syncService.serverURL = newURL + syncService.authToken = newToken + dismiss() + } + .fontWeight(.semibold) + } + } + } + .onAppear { + serverURL = syncService.serverURL + authToken = syncService.authToken + } + .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { + Button("Cancel", role: .cancel) {} + Button("Disconnect", role: .destructive) { + syncService.disconnect() + dismiss() + } + } message: { + Text( + "This will sign you out but keep your server settings. You'll need to test the connection again to sync." + ) + } + .alert("Connection Test", isPresented: $showingTestResult) { + Button("OK") {} + } message: { + Text(testResultMessage) + } + } + + private func testConnection() { + isTesting = true + + let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) + + // Store original values in case test fails + let originalURL = syncService.serverURL + let originalToken = syncService.authToken + + Task { + do { + // Temporarily set the values for testing + syncService.serverURL = testURL + syncService.authToken = testToken + + try await syncService.testConnection() + + await MainActor.run { + isTesting = false + testResultMessage = + "Connection successful! You can now save and sync your data." + showingTestResult = true + } + } catch { + // Restore original values if test failed + syncService.serverURL = originalURL + syncService.authToken = originalToken + + await MainActor.run { + isTesting = false + testResultMessage = "Connection failed: \(error.localizedDescription)" + showingTestResult = true + } + } + } + } +} + +// Removed AutoSyncSettingsView - now using simple toggle in main settings + +extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content + ) -> some View { + + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} + struct ImportDataView: View { @EnvironmentObject var dataManager: ClimbingDataManager @Environment(\.dismiss) private var dismiss diff --git a/sync-server/.env.example b/sync-server/.env.example new file mode 100644 index 0000000..bc6aa89 --- /dev/null +++ b/sync-server/.env.example @@ -0,0 +1,14 @@ +# OpenClimb Sync Server Configuration + +# Required: Secret token for authentication +# Generate a secure random token and share it between your apps and server +AUTH_TOKEN=your-secure-secret-token-here + +# Optional: Port to run the server on (default: 8080) +PORT=8080 + +# Optional: Path to store the sync data (default: ./data/climb_data.json) +DATA_FILE=./data/climb_data.json + +# Optional: Directory to store images (default: ./data/images) +IMAGES_DIR=./data/images diff --git a/sync-server/.gitignore b/sync-server/.gitignore new file mode 100644 index 0000000..9fc8fde --- /dev/null +++ b/sync-server/.gitignore @@ -0,0 +1,16 @@ +# Binaries +sync-server +openclimb-sync + +# Go workspace file +go.work + +# Data directory +data/ + +# Environment files +.env +.env.local + +# OS generated files +.DS_Store diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile new file mode 100644 index 0000000..25442c0 --- /dev/null +++ b/sync-server/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o sync-server . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ + +COPY --from=builder /app/sync-server . + +EXPOSE 8080 +CMD ["./sync-server"] diff --git a/sync-server/docker-compose.yml b/sync-server/docker-compose.yml new file mode 100644 index 0000000..b1bb740 --- /dev/null +++ b/sync-server/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + openclimb-sync: + build: . + ports: + - "8080:8080" + environment: + - AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here} + - DATA_FILE=/data/climb_data.json + - IMAGES_DIR=/data/images + volumes: + - ./data:/data + restart: unless-stopped diff --git a/sync-server/go.mod b/sync-server/go.mod new file mode 100644 index 0000000..3103696 --- /dev/null +++ b/sync-server/go.mod @@ -0,0 +1,3 @@ +module openclimb-sync + +go 1.25 diff --git a/sync-server/main.go b/sync-server/main.go new file mode 100644 index 0000000..7034a3f --- /dev/null +++ b/sync-server/main.go @@ -0,0 +1,358 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type ClimbDataBackup struct { + ExportedAt string `json:"exportedAt"` + Version string `json:"version"` + FormatVersion string `json:"formatVersion"` + Gyms []BackupGym `json:"gyms"` + Problems []BackupProblem `json:"problems"` + Sessions []BackupClimbSession `json:"sessions"` + Attempts []BackupAttempt `json:"attempts"` +} + +type BackupGym struct { + ID string `json:"id"` + Name string `json:"name"` + Location *string `json:"location,omitempty"` + SupportedClimbTypes []string `json:"supportedClimbTypes"` + DifficultySystems []string `json:"difficultySystems"` + CustomDifficultyGrades []string `json:"customDifficultyGrades"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type BackupProblem struct { + ID string `json:"id"` + GymID string `json:"gymId"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + ClimbType string `json:"climbType"` + Difficulty DifficultyGrade `json:"difficulty"` + Tags []string `json:"tags"` + Location *string `json:"location,omitempty"` + ImagePaths []string `json:"imagePaths,omitempty"` + IsActive bool `json:"isActive"` + DateSet *string `json:"dateSet,omitempty"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type DifficultyGrade struct { + System string `json:"system"` + Grade string `json:"grade"` + NumericValue int `json:"numericValue"` +} + +type BackupClimbSession struct { + ID string `json:"id"` + GymID string `json:"gymId"` + Date string `json:"date"` + StartTime *string `json:"startTime,omitempty"` + EndTime *string `json:"endTime,omitempty"` + Duration *int64 `json:"duration,omitempty"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type BackupAttempt struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + ProblemID string `json:"problemId"` + Result string `json:"result"` + HighestHold *string `json:"highestHold,omitempty"` + Notes *string `json:"notes,omitempty"` + Duration *int64 `json:"duration,omitempty"` + RestTime *int64 `json:"restTime,omitempty"` + Timestamp string `json:"timestamp"` + CreatedAt string `json:"createdAt"` +} + +type SyncServer struct { + authToken string + dataFile string + imagesDir string +} + +func (s *SyncServer) authenticate(r *http.Request) bool { + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return false + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + return subtle.ConstantTimeCompare([]byte(token), []byte(s.authToken)) == 1 +} + +func (s *SyncServer) loadData() (*ClimbDataBackup, error) { + log.Printf("Loading data from: %s", s.dataFile) + + if _, err := os.Stat(s.dataFile); os.IsNotExist(err) { + log.Printf("Data file does not exist, creating empty backup") + return &ClimbDataBackup{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, nil + } + + data, err := os.ReadFile(s.dataFile) + if err != nil { + log.Printf("Failed to read data file: %v", err) + return nil, err + } + + log.Printf("Read %d bytes from data file", len(data)) + log.Printf("File content preview: %s", string(data[:min(200, len(data))])) + + var backup ClimbDataBackup + if err := json.Unmarshal(data, &backup); err != nil { + log.Printf("Failed to unmarshal JSON: %v", err) + return nil, err + } + + log.Printf("Loaded backup: gyms=%d, problems=%d, sessions=%d, attempts=%d", + len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) + + return &backup, nil +} + +func (s *SyncServer) saveData(backup *ClimbDataBackup) error { + backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return err + } + + dir := filepath.Dir(s.dataFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Ensure images directory exists + if err := os.MkdirAll(s.imagesDir, 0755); err != nil { + return err + } + + return os.WriteFile(s.dataFile, data, 0644) +} + +func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized access attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + log.Printf("GET /sync request from %s", r.RemoteAddr) + backup, err := s.loadData() + if err != nil { + log.Printf("Failed to load data: %v", err) + http.Error(w, "Failed to load data", http.StatusInternalServerError) + return + } + + log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", + r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(backup) +} + +func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized sync attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var backup ClimbDataBackup + if err := json.NewDecoder(r.Body).Decode(&backup); err != nil { + log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err) + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if err := s.saveData(&backup); err != nil { + log.Printf("Failed to save data: %v", err) + http.Error(w, "Failed to save data", http.StatusInternalServerError) + return + } + + log.Printf("Data synced by %s", r.RemoteAddr) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(backup) +} + +func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *SyncServer) handleImageUpload(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized image upload attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Missing filename parameter", http.StatusBadRequest) + return + } + + imageData, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read image data", http.StatusBadRequest) + return + } + + imagePath := filepath.Join(s.imagesDir, filename) + if err := os.WriteFile(imagePath, imageData, 0644); err != nil { + log.Printf("Failed to save image %s: %v", filename, err) + http.Error(w, "Failed to save image", http.StatusInternalServerError) + return + } + + log.Printf("Image uploaded: %s (%d bytes) by %s", filename, len(imageData), r.RemoteAddr) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "uploaded"}) +} + +func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request) { + if !s.authenticate(r) { + log.Printf("Unauthorized image download attempt from %s", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Missing filename parameter", http.StatusBadRequest) + return + } + + imagePath := filepath.Join(s.imagesDir, filename) + imageData, err := os.ReadFile(imagePath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "Image not found", http.StatusNotFound) + } else { + http.Error(w, "Failed to read image", http.StatusInternalServerError) + } + return + } + + // Set appropriate content type based on file extension + ext := filepath.Ext(filename) + switch ext { + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".gif": + w.Header().Set("Content-Type", "image/gif") + case ".webp": + w.Header().Set("Content-Type", "image/webp") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + + w.WriteHeader(http.StatusOK) + w.Write(imageData) +} + +func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleGet(w, r) + case http.MethodPut: + s.handlePut(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func main() { + authToken := os.Getenv("AUTH_TOKEN") + if authToken == "" { + log.Fatal("AUTH_TOKEN environment variable is required") + } + + dataFile := os.Getenv("DATA_FILE") + if dataFile == "" { + dataFile = "./data/climb_data.json" + } + + imagesDir := os.Getenv("IMAGES_DIR") + if imagesDir == "" { + imagesDir = "./data/images" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + server := &SyncServer{ + authToken: authToken, + dataFile: dataFile, + imagesDir: imagesDir, + } + + http.HandleFunc("/sync", server.handleSync) + http.HandleFunc("/health", server.handleHealth) + http.HandleFunc("/images/upload", server.handleImageUpload) + http.HandleFunc("/images/download", server.handleImageDownload) + + fmt.Printf("OpenClimb sync server starting on port %s\n", port) + fmt.Printf("Data file: %s\n", dataFile) + fmt.Printf("Images directory: %s\n", imagesDir) + fmt.Printf("Health check available at /health\n") + fmt.Printf("Image upload: POST /images/upload?filename=\n") + fmt.Printf("Image download: GET /images/download?filename=\n") + + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/sync-server/run.sh b/sync-server/run.sh new file mode 100755 index 0000000..76a3382 --- /dev/null +++ b/sync-server/run.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# OpenClimb Sync Server Runner +set -e + +# Default values +AUTH_TOKEN=${AUTH_TOKEN:-} +PORT=${PORT:-8080} +DATA_FILE=${DATA_FILE:-./data/climb_data.json} + +# Check if AUTH_TOKEN is set +if [ -z "$AUTH_TOKEN" ]; then + echo "Error: AUTH_TOKEN environment variable must be set" + echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh" + echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh" + exit 1 +fi + +# Create data directory if it doesn't exist +mkdir -p "$(dirname "$DATA_FILE")" + +# Build and run +echo "Building OpenClimb sync server..." +go build -o sync-server . + +echo "Starting server on port $PORT" +echo "Data will be stored in: $DATA_FILE" +echo "Images will be stored in: ${IMAGES_DIR:-./data/images}" +echo "Use Authorization: Bearer $AUTH_TOKEN in your requests" +echo "" + +exec ./sync-server