diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..1ae4138 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,43 @@ +name: OpenClimb Docker Deploy +on: + push: + branches: [main] + paths: + - "sync/**" + - ".github/workflows/deploy.yml" + pull_request: + branches: [main] + paths: + - "sync/**" + - ".github/workflows/deploy.yml" + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.REPO_HOST }} + username: ${{ github.repository_owner }} + password: ${{ secrets.DEPLOY_TOKEN }} + + - name: Build and push sync-server + uses: docker/build-push-action@v4 + with: + context: ./sync + file: ./sync/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }} + ${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest 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..a169fc6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 27 - versionName = "1.6.0" + versionCode = 28 + versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -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.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 0a60cb7..63bf078 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -381,6 +382,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -394,7 +396,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -414,7 +416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -437,7 +439,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -457,7 +459,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -479,7 +481,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -490,7 +492,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -509,7 +511,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -520,7 +522,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 2f1b85e..7fb5b30 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme b/ios/OpenClimb.xcodeproj/xcshareddata/xcschemes/OpenClimb.xcscheme index e69de29..8f80ddb 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..21a4b03 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 + await 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..a88ccc4 --- /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 + _ = 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/DEPLOY.md b/sync-server/DEPLOY.md new file mode 100644 index 0000000..b34440d --- /dev/null +++ b/sync-server/DEPLOY.md @@ -0,0 +1,303 @@ +# OpenClimb Sync Server Deployment Guide + +This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system. + +## Overview + +The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment. + +## Prerequisites + +- Docker and Docker Compose installed +- Access to the container registry (configured in GitHub secrets) +- Basic understanding of Docker deployments + +## Quick Start + +### 1. Automated Deployment (Recommended) + +```bash +# Clone the repository +git clone +cd OpenClimb/sync-server + +# Run the deployment script +./deploy.sh +``` + +The script will: +- Create necessary directories +- Pull the latest container image +- Stop any existing containers +- Start the new container +- Verify deployment success + +### 2. Manual Deployment + +```bash +# Pull the latest image +docker pull your-registry.com/username/openclimb-sync-server:latest + +# Create environment file +cp .env.example .env.prod +# Edit .env.prod with your configuration + +# Deploy with docker-compose +docker-compose -f docker-compose.prod.yml up -d +``` + +## Configuration + +### Environment Variables + +Create a `.env.prod` file with the following variables: + +```bash +# Container registry settings +REPO_HOST=your-registry.example.com +REPO_OWNER=your-username + +# Server configuration +AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random +PORT=8080 + +# Optional: Custom domain (for Traefik) +TRAEFIK_HOST=sync.openclimb.example.com +``` + +### Required Secrets (GitHub) + +Configure these secrets in your GitHub repository settings: + +- `REPO_HOST`: Your container registry hostname +- `DEPLOY_TOKEN`: Authentication token for the registry + +## Container Build Process + +The GitHub Action (`sync-server-deploy.yml`) automatically: + +1. **Triggers on:** + - Push to `main` branch (when sync-server files change) + - Pull requests to `main` branch + +2. **Build Process:** + - Uses multi-stage Docker build + - Compiles Go binary in builder stage + - Creates minimal Alpine-based runtime image + - Pushes to container registry with tags: + - `latest` (always points to newest) + - `` (specific version) + +3. **Caching:** + - Uses GitHub Actions cache for faster builds + - Incremental builds when possible + +## Deployment Options + +### Option 1: Simple Docker Run +```bash +docker run -d \ + --name openclimb-sync-server \ + -p 8080:8080 \ + -v $(pwd)/data:/root/data \ + -e AUTH_TOKEN=your-token-here \ + your-registry.com/username/openclimb-sync-server:latest +``` + +### Option 2: Docker Compose (Recommended) +```bash +docker-compose -f docker-compose.prod.yml up -d +``` + +### Option 3: Kubernetes +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openclimb-sync-server +spec: + replicas: 1 + selector: + matchLabels: + app: openclimb-sync-server + template: + metadata: + labels: + app: openclimb-sync-server + spec: + containers: + - name: sync-server + image: your-registry.com/username/openclimb-sync-server:latest + ports: + - containerPort: 8080 + env: + - name: AUTH_TOKEN + valueFrom: + secretKeyRef: + name: openclimb-secrets + key: auth-token + volumeMounts: + - name: data-volume + mountPath: /root/data + volumes: + - name: data-volume + persistentVolumeClaim: + claimName: openclimb-data +``` + +## Data Persistence + +The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data: + +```bash +# Local directory mounting +-v $(pwd)/data:/root/data + +# Named volume (recommended for production) +-v openclimb-data:/root/data +``` + +### Data Structure +``` +data/ +├── climb_data.json # Main sync data +├── images/ # Uploaded images +│ ├── problem_*.jpg +│ └── ... +└── logs/ # Server logs (optional) +``` + +## Monitoring and Maintenance + +### Health Check +```bash +curl http://localhost:8080/health +``` + +### View Logs +```bash +# Docker Compose +docker-compose -f docker-compose.prod.yml logs -f + +# Direct Docker +docker logs -f openclimb-sync-server +``` + +### Update to Latest Version +```bash +# Using deploy script +./deploy.sh + +# Manual update +docker-compose -f docker-compose.prod.yml pull +docker-compose -f docker-compose.prod.yml up -d +``` + +## Reverse Proxy Setup (Optional) + +### Nginx +```nginx +server { + listen 80; + server_name sync.openclimb.example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Traefik (Labels included in docker-compose.prod.yml) +```yaml +labels: + - "traefik.enable=true" + - "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)" + - "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt" +``` + +## Security Considerations + +1. **AUTH_TOKEN**: Use a long, random token (32+ characters) +2. **HTTPS**: Always use HTTPS in production (via reverse proxy) +3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly +4. **Updates**: Regularly update to the latest container image +5. **Backups**: Regularly backup the `data/` directory + +## Troubleshooting + +### Container Won't Start +```bash +# Check logs +docker logs openclimb-sync-server + +# Common issues: +# - Missing AUTH_TOKEN environment variable +# - Port 8080 already in use +# - Insufficient permissions on data directory +``` + +### Sync Fails from Mobile Apps +```bash +# Verify server is accessible +curl -H "Authorization: Bearer your-token" http://your-server:8080/sync + +# Check server logs for authentication errors +docker logs openclimb-sync-server | grep "401\|403" +``` + +### Image Upload Issues +```bash +# Check disk space +df -h + +# Verify data directory permissions +ls -la data/ +``` + +## Performance Tuning + +For high-load deployments: + +```yaml +# docker-compose.prod.yml +services: + openclimb-sync-server: + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' +``` + +## Backup Strategy + +```bash +#!/bin/bash +# backup.sh - Run daily via cron + +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="/backups/openclimb" + +# Create backup directory +mkdir -p "$BACKUP_DIR" + +# Backup data directory +tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \ + -C /path/to/sync-server data/ + +# Keep only last 30 days +find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete +``` + +## Support + +- **Issues**: Create an issue in the GitHub repository +- **Documentation**: Check the main OpenClimb README +- **Logs**: Always diff --git a/sync/.env.example b/sync/.env.example new file mode 100644 index 0000000..bc6aa89 --- /dev/null +++ b/sync/.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/.gitignore b/sync/.gitignore new file mode 100644 index 0000000..9fc8fde --- /dev/null +++ b/sync/.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/Dockerfile b/sync/Dockerfile new file mode 100644 index 0000000..25442c0 --- /dev/null +++ b/sync/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/docker-compose.yml b/sync/docker-compose.yml new file mode 100644 index 0000000..ca7977d --- /dev/null +++ b/sync/docker-compose.yml @@ -0,0 +1,12 @@ +services: + openclimb-sync: + image: ${IMAGE} + 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/go.mod b/sync/go.mod new file mode 100644 index 0000000..3103696 --- /dev/null +++ b/sync/go.mod @@ -0,0 +1,3 @@ +module openclimb-sync + +go 1.25 diff --git a/sync/main.go b/sync/main.go new file mode 100644 index 0000000..7034a3f --- /dev/null +++ b/sync/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/run.sh b/sync/run.sh new file mode 100755 index 0000000..76a3382 --- /dev/null +++ b/sync/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 diff --git a/sync/version.md b/sync/version.md new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/sync/version.md @@ -0,0 +1 @@ +1.0.0