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/README.md b/README.md index b4df681..861e258 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,18 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems, ## Versions -- Android:1.4.2 -- iOS: 1.0.1 +- Android: 1.7.0 +- iOS: 1.2.0 +- Sync: 1.0.0 + +## Stability +- Clients: 8/10 +- Server: 10/10 +- Schema: 9/10 (No more breaking changes) + +## Self-Hosted Sync Server + +You can run your own sync server to keep your data in sync across devices. The server is lightweight and easy to set up. See the server docker-compose file for an example. ## Download diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1b83931..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 = 26 - versionName = "1.5.1" + 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 new file mode 100644 index 0000000..f8fae25 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -0,0 +1,233 @@ +package com.atridad.openclimb.data.format + +import com.atridad.openclimb.data.model.* +import kotlinx.serialization.Serializable + +/** Root structure for OpenClimb backup data */ +@Serializable +data class ClimbDataBackup( + val exportedAt: String, + val version: String = "2.0", + val formatVersion: String = "2.0", + val gyms: List, + val problems: List, + val sessions: List, + val attempts: List +) + +/** Platform-neutral gym representation for backup/restore */ +@Serializable +data class BackupGym( + val id: String, + val name: String, + 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 + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupGym from native Android Gym model */ + fun fromGym(gym: Gym): BackupGym { + return BackupGym( + id = gym.id, + name = gym.name, + location = gym.location, + supportedClimbTypes = gym.supportedClimbTypes, + difficultySystems = gym.difficultySystems, + customDifficultyGrades = gym.customDifficultyGrades, + notes = gym.notes, + createdAt = gym.createdAt, + updatedAt = gym.updatedAt + ) + } + } + + /** Convert to native Android Gym model */ + fun toGym(): Gym { + return Gym( + id = id, + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral problem representation for backup/restore */ +@Serializable +data class BackupProblem( + val id: String, + val gymId: String, + val name: String? = null, + val description: String? = null, + val climbType: ClimbType, + val difficulty: DifficultyGrade, + val tags: List = emptyList(), + val location: String? = null, + val imagePaths: List? = null, + val isActive: Boolean = true, + val dateSet: String? = null, // ISO 8601 format + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupProblem from native Android Problem model */ + fun fromProblem(problem: Problem): BackupProblem { + 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 = + 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, + createdAt = problem.createdAt, + updatedAt = problem.updatedAt + ) + } + } + + /** Convert to native Android Problem model */ + fun toProblem(): Problem { + return Problem( + id = id, + gymId = gymId, + name = name, + description = description, + climbType = climbType, + difficulty = difficulty, + tags = tags, + location = location, + imagePaths = imagePaths ?: emptyList(), + isActive = isActive, + dateSet = dateSet, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } + + /** Create a copy with updated image paths for import processing */ + fun withUpdatedImagePaths(newImagePaths: List): BackupProblem { + return copy(imagePaths = newImagePaths.ifEmpty { null }) + } +} + +/** Platform-neutral climb session representation for backup/restore */ +@Serializable +data class BackupClimbSession( + val id: String, + val gymId: String, + val date: String, // ISO 8601 format + val startTime: String? = null, // ISO 8601 format + val endTime: String? = null, // ISO 8601 format + val duration: Long? = null, // Duration in seconds + val status: SessionStatus, + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupClimbSession from native Android ClimbSession model */ + fun fromClimbSession(session: ClimbSession): BackupClimbSession { + return BackupClimbSession( + id = session.id, + gymId = session.gymId, + date = session.date, + startTime = session.startTime, + endTime = session.endTime, + duration = session.duration, + status = session.status, + notes = session.notes, + createdAt = session.createdAt, + updatedAt = session.updatedAt + ) + } + } + + /** Convert to native Android ClimbSession model */ + fun toClimbSession(): ClimbSession { + return ClimbSession( + id = id, + gymId = gymId, + date = date, + startTime = startTime, + endTime = endTime, + duration = duration, + status = status, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral attempt representation for backup/restore */ +@Serializable +data class BackupAttempt( + val id: String, + val sessionId: String, + val problemId: String, + val result: AttemptResult, + val highestHold: String? = null, + val notes: String? = null, + val duration: Long? = null, // Duration in seconds + val restTime: Long? = null, // Rest time in seconds + val timestamp: String, // ISO 8601 format + val createdAt: String // ISO 8601 format +) { + companion object { + /** Create BackupAttempt from native Android Attempt model */ + fun fromAttempt(attempt: Attempt): BackupAttempt { + return BackupAttempt( + id = attempt.id, + sessionId = attempt.sessionId, + problemId = attempt.problemId, + result = attempt.result, + highestHold = attempt.highestHold, + notes = attempt.notes, + duration = attempt.duration, + restTime = attempt.restTime, + timestamp = attempt.timestamp, + createdAt = attempt.createdAt + ) + } + } + + /** Convert to native Android Attempt model */ + fun toAttempt(): Attempt { + return Attempt( + id = id, + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = createdAt + ) + } +} 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 e4d3e0d..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 @@ -2,10 +2,16 @@ package com.atridad.openclimb.data.repository import android.content.Context import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.format.BackupAttempt +import com.atridad.openclimb.data.format.BackupClimbSession +import com.atridad.openclimb.data.format.BackupGym +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup import com.atridad.openclimb.data.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 @@ -15,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 @@ -24,19 +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) - fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + 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) - fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + 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() @@ -45,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()) { @@ -63,73 +107,25 @@ 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) - - // ZIP Export with images - Single format for reliability - suspend fun exportAllDataToZip(directory: File? = null): File { - try { - // Collect all data with proper error handling - val allGyms = gymDao.getAllGyms().first() - val allProblems = problemDao.getAllProblems().first() - val allSessions = sessionDao.getAllSessions().first() - val allAttempts = attemptDao.getAllAttempts().first() - - // Validate data integrity before export - validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - - val exportData = - ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - version = "2.0", - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts - ) - - // Collect all referenced image paths and validate they exist - val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() - val validImagePaths = - referencedImagePaths - .filter { imagePath -> - try { - val imageFile = - com.atridad.openclimb.utils.ImageUtils.getImageFile( - context, - imagePath - ) - imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { - false - } - } - .toSet() - - // Log any missing images for debugging - val missingImages = referencedImagePaths - validImagePaths - if (missingImages.isNotEmpty()) { - android.util.Log.w( - "ClimbRepository", - "Some referenced images are missing: $missingImages" - ) - } - - return ZipExportImportUtils.createExportZip( - context = context, - exportData = exportData, - referencedImagePaths = validImagePaths, - directory = directory - ) - } catch (e: Exception) { - throw Exception("Export failed: ${e.message}") - } + 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 { - // Collect all data with proper error handling + // Collect all data val allGyms = gymDao.getAllGyms().first() val allProblems = problemDao.getAllProblems().first() val allSessions = sessionDao.getAllSessions().first() @@ -138,14 +134,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - val exportData = - ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = DateFormatUtils.nowISO8601(), version = "2.0", - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts + 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) } ) // Collect all referenced image paths and validate they exist @@ -160,7 +158,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) imagePath ) imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -169,7 +167,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) ZipExportImportUtils.createExportZipToUri( context = context, uri = uri, - exportData = exportData, + exportData = backupData, referencedImagePaths = validImagePaths ) } catch (e: Exception) { @@ -195,7 +193,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Parse and validate the data structure val importData = try { - json.decodeFromString(importResult.jsonContent) + json.decodeFromString(importResult.jsonContent) } catch (e: Exception) { throw Exception("Invalid data format: ${e.message}") } @@ -209,52 +207,75 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) problemDao.deleteAllProblems() gymDao.deleteAllGyms() - // Import gyms first (problems depend on gyms) - importData.gyms.forEach { gym -> + // Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data + // state updates + importData.gyms.forEach { backupGym -> try { - gymDao.insertGym(gym) + gymDao.insertGym(backupGym.toGym()) } catch (e: Exception) { - throw Exception("Failed to import gym ${gym.name}: ${e.message}") + throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") } } // Import problems with updated image paths - val updatedProblems = + val updatedBackupProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) - updatedProblems.forEach { problem -> + // Import problems (depends on gyms) - use DAO directly + updatedBackupProblems.forEach { backupProblem -> try { - problemDao.insertProblem(problem) + problemDao.insertProblem(backupProblem.toProblem()) } catch (e: Exception) { - throw Exception("Failed to import problem ${problem.name}: ${e.message}") + throw Exception( + "Failed to import problem '${backupProblem.name}': ${e.message}" + ) } } - // Import sessions - importData.sessions.forEach { session -> + // Import sessions - use DAO directly + importData.sessions.forEach { backupSession -> try { - sessionDao.insertSession(session) + sessionDao.insertSession(backupSession.toClimbSession()) } catch (e: Exception) { - throw Exception("Failed to import session: ${e.message}") + throw Exception("Failed to import session '${backupSession.id}': ${e.message}") } } - // Import attempts last (depends on problems and sessions) - importData.attempts.forEach { attempt -> + // Import attempts last (depends on problems and sessions) - use DAO directly + importData.attempts.forEach { backupAttempt -> try { - attemptDao.insertAttempt(attempt) + attemptDao.insertAttempt(backupAttempt.toAttempt()) } catch (e: Exception) { - throw Exception("Failed to import attempt: ${e.message}") + 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, @@ -291,7 +312,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } - private fun validateImportData(importData: ClimbDataExport) { + private fun validateImportData(importData: ClimbDataBackup) { if (importData.gyms.isEmpty()) { throw Exception("Import data is invalid: no gyms found") } @@ -312,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() @@ -320,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 @@ -339,13 +388,3 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } } - -@kotlinx.serialization.Serializable -data class ClimbDataExport( - val exportedAt: String, - val version: String = "2.0", - val gyms: List, - val problems: List, - val sessions: List, - val attempts: List -) 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/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt index 9bd349b..a8ee1a6 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -1,7 +1,8 @@ package com.atridad.openclimb.utils import android.content.Context -import kotlinx.serialization.json.Json +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -10,13 +11,15 @@ import java.time.LocalDateTime import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object ZipExportImportUtils { - + private const val DATA_JSON_FILENAME = "data.json" private const val IMAGES_DIR_NAME = "images" private const val METADATA_FILENAME = "metadata.txt" - + /** * Creates a ZIP file containing the JSON data and all referenced images * @param context Android context @@ -26,19 +29,26 @@ object ZipExportImportUtils { * @return The created ZIP file */ fun createExportZip( - context: Context, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set, - directory: File? = null + context: Context, + exportData: ClimbDataBackup, + referencedImagePaths: Set, + directory: File? = null ): File { - val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb") + val exportDir = + directory + ?: File( + context.getExternalFilesDir( + android.os.Environment.DIRECTORY_DOCUMENTS + ), + "OpenClimb" + ) if (!exportDir.exists()) { exportDir.mkdirs() } - + val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") - + try { ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> // Add metadata file first @@ -47,19 +57,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -68,31 +78,39 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } zipOut.closeEntry() successfulImages++ } else { - android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath") + android.util.Log.w( + "ZipExportImportUtils", + "Image file not found or empty: $imagePath" + ) } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - + // Log export summary - android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included") + android.util.Log.i( + "ZipExportImportUtils", + "Export completed: ${successfulImages}/${referencedImagePaths.size} images included" + ) } - + // Validate the created ZIP file if (!zipFile.exists() || zipFile.length() == 0L) { throw IOException("Failed to create ZIP file: file is empty or doesn't exist") } - + return zipFile - } catch (e: Exception) { // Clean up failed export if (zipFile.exists()) { @@ -101,7 +119,7 @@ object ZipExportImportUtils { throw IOException("Failed to create export ZIP: ${e.message}") } } - + /** * Creates a ZIP file and writes it to a provided URI * @param context Android context @@ -110,10 +128,10 @@ object ZipExportImportUtils { * @param referencedImagePaths Set of image paths referenced in the data */ fun createExportZipToUri( - context: Context, - uri: android.net.Uri, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + context: Context, + uri: android.net.Uri, + exportData: ClimbDataBackup, + referencedImagePaths: Set ) { try { context.contentResolver.openOutputStream(uri)?.use { outputStream -> @@ -124,19 +142,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -145,7 +163,7 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } @@ -153,22 +171,28 @@ object ZipExportImportUtils { successfulImages++ } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - - android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included") + + android.util.Log.i( + "ZipExportImportUtils", + "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" + ) } - } ?: throw IOException("Could not open output stream") - + } + ?: throw IOException("Could not open output stream") } catch (e: Exception) { throw IOException("Failed to create export ZIP to URI: ${e.message}") } } - + private fun createMetadata( - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + exportData: ClimbDataBackup, + referencedImagePaths: Set ): String { return buildString { appendLine("OpenClimb Export Metadata") @@ -183,15 +207,13 @@ object ZipExportImportUtils { appendLine("Format: ZIP with embedded JSON data and images") } } - - /** - * Data class to hold extraction results - */ + + /** Data class to hold extraction results */ data class ImportResult( - val jsonContent: String, - val importedImagePaths: Map // original filename -> new relative path + val jsonContent: String, + val importedImagePaths: Map // original filename -> new relative path ) - + /** * Extracts a ZIP file and returns the JSON content and imported image paths * @param context Android context @@ -200,106 +222,125 @@ object ZipExportImportUtils { */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" - var metadataContent = "" val importedImagePaths = mutableMapOf() var foundRequiredFiles = mutableSetOf() - + try { ZipInputStream(FileInputStream(zipFile)).use { zipIn -> var entry = zipIn.nextEntry - + while (entry != null) { when { entry.name == METADATA_FILENAME -> { // Read metadata for validation - metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) + val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("metadata") - android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}") + android.util.Log.i( + "ZipExportImportUtils", + "Found metadata: ${metadataContent.lines().take(3).joinToString()}" + ) } - entry.name == DATA_JSON_FILENAME -> { // Read JSON data jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("data") } - entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { // Extract image file val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") - + try { // Create temporary file to hold the extracted image - val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir) - - FileOutputStream(tempFile).use { output -> - zipIn.copyTo(output) - } - + val tempFile = + File.createTempFile( + "import_image_", + "_$originalFilename", + context.cacheDir + ) + + FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } + // Validate the extracted image if (tempFile.exists() && tempFile.length() > 0) { // Import the image to permanent storage val newPath = ImageUtils.importImageFile(context, tempFile) if (newPath != null) { importedImagePaths[originalFilename] = newPath - android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath") + android.util.Log.d( + "ZipExportImportUtils", + "Successfully imported image: $originalFilename -> $newPath" + ) } else { - android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename") + android.util.Log.w( + "ZipExportImportUtils", + "Failed to import image: $originalFilename" + ) } } else { - android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename") + android.util.Log.w( + "ZipExportImportUtils", + "Extracted image is empty: $originalFilename" + ) } - + // Clean up temp file tempFile.delete() - } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to process image $originalFilename: ${e.message}" + ) } } - else -> { - android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}") + android.util.Log.d( + "ZipExportImportUtils", + "Skipping ZIP entry: ${entry.name}" + ) } } - + zipIn.closeEntry() entry = zipIn.nextEntry } } - + // Validate that we found the required files if (!foundRequiredFiles.contains("data")) { throw IOException("Invalid ZIP file: data.json not found") } - + if (jsonContent.isBlank()) { throw IOException("Invalid ZIP file: data.json is empty") } - - android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed") - + + android.util.Log.i( + "ZipExportImportUtils", + "Import extraction completed: ${importedImagePaths.size} images processed" + ) + return ImportResult(jsonContent, importedImagePaths) - } catch (e: Exception) { throw IOException("Failed to extract import ZIP: ${e.message}") } } /** - * Updates image paths in a problem list after import - * This function maps the old image paths to the new ones after import + * Updates image paths in a problem list after import This function maps the old image paths to + * the new ones after import */ fun updateProblemImagePaths( - problems: List, - imagePathMapping: Map - ): List { + problems: List, + imagePathMapping: Map + ): List { return problems.map { problem -> - val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath -> - // Extract filename from the old path - val filename = oldPath.substringAfterLast("/") - imagePathMapping[filename] - } - problem.copy(imagePaths = updatedImagePaths) + val updatedImagePaths = + (problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> + // Extract filename from the old path + val filename = oldPath.substringAfterLast("/") + imagePathMapping[filename] + } + problem.withUpdatedImagePaths(updatedImagePaths) } } } 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 index e708b1c..178a980 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/test_backup/ClimbRepository.kt b/android/test_backup/ClimbRepository.kt new file mode 100644 index 0000000..fe3483c --- /dev/null +++ b/android/test_backup/ClimbRepository.kt @@ -0,0 +1,383 @@ +package com.atridad.openclimb.data.repository + +import android.content.Context +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.model.* +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 + +class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { + private val gymDao = database.gymDao() + private val problemDao = database.problemDao() + private val sessionDao = database.climbSessionDao() + private val attemptDao = database.attemptDao() + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + // 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) + fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + + // 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) + fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + + // Session operations + fun getAllSessions(): Flow> = sessionDao.getAllSessions() + suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) + fun getSessionsByGym(gymId: String): Flow> = + 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 getLastUsedGym(): Gym? { + val recentSessions = sessionDao.getRecentSessions(1).first() + return if (recentSessions.isNotEmpty()) { + getGymById(recentSessions.first().gymId) + } else { + null + } + } + + // Attempt operations + fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() + fun getAttemptsBySession(sessionId: String): Flow> = + 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) + + // ZIP Export with images - Single format for reliability + suspend fun exportAllDataToZip(directory: File? = null): File { + return try { + // Collect all data with proper error handling + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + // Validate data integrity before export + validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) + + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + // Log any missing images for debugging + val missingImages = referencedImagePaths - validImagePaths + if (missingImages.isNotEmpty()) { + android.util.Log.w( + "ClimbRepository", + "Some referenced images are missing: $missingImages" + ) + } + + ZipExportImportUtils.createExportZip( + context = context, + exportData = backupData, + referencedImagePaths = validImagePaths, + directory = directory + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + suspend fun exportAllDataToZipUri(uri: android.net.Uri) { + try { + // Collect all data + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + // Validate data integrity before export + validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) + + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = backupData, + referencedImagePaths = validImagePaths + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + suspend fun importDataFromZip(file: File) { + try { + // Validate the ZIP file + if (!file.exists() || file.length() == 0L) { + throw Exception("Invalid ZIP file: file is empty or doesn't exist") + } + + // Extract and validate the ZIP contents + val importResult = ZipExportImportUtils.extractImportZip(context, file) + + // Validate JSON content + if (importResult.jsonContent.isBlank()) { + throw Exception("Invalid ZIP file: no data.json found or empty content") + } + + // Parse and validate the data structure + val importData = + try { + json.decodeFromString(importResult.jsonContent) + } catch (e: Exception) { + throw Exception("Invalid data format: ${e.message}") + } + + // Validate data integrity + validateImportData(importData) + + // Clear existing data to avoid conflicts + attemptDao.deleteAllAttempts() + sessionDao.deleteAllSessions() + problemDao.deleteAllProblems() + gymDao.deleteAllGyms() + + // Import gyms first (problems depend on gyms) + importData.gyms.forEach { backupGym -> + try { + gymDao.insertGym(backupGym.toGym()) + } catch (e: Exception) { + throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") + } + } + + // Import problems with updated image paths + val updatedBackupProblems = + ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + importResult.importedImagePaths + ) + + // Import problems (depends on gyms) + updatedBackupProblems.forEach { backupProblem -> + try { + problemDao.insertProblem(backupProblem.toProblem()) + } catch (e: Exception) { + throw Exception( + "Failed to import problem '${backupProblem.name}': ${e.message}" + ) + } + } + + // Import sessions + importData.sessions.forEach { backupSession -> + try { + sessionDao.insertSession(backupSession.toClimbSession()) + } catch (e: Exception) { + throw Exception("Failed to import session '${backupSession.id}': ${e.message}") + } + } + + // Import attempts last (depends on problems and sessions) + importData.attempts.forEach { backupAttempt -> + try { + attemptDao.insertAttempt(backupAttempt.toAttempt()) + } catch (e: Exception) { + throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") + } + } + } catch (e: Exception) { + throw Exception("Import failed: ${e.message}") + } + } + + private fun validateDataIntegrity( + gyms: List, + problems: List, + sessions: List, + attempts: List + ) { + // Validate that all problems reference valid gyms + val gymIds = gyms.map { it.id }.toSet() + val invalidProblems = problems.filter { it.gymId !in gymIds } + if (invalidProblems.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" + ) + } + + // Validate that all sessions reference valid gyms + val invalidSessions = sessions.filter { it.gymId !in gymIds } + if (invalidSessions.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" + ) + } + + // Validate that all attempts reference valid problems and sessions + val problemIds = problems.map { it.id }.toSet() + val sessionIds = sessions.map { it.id }.toSet() + + val invalidAttempts = + attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } + if (invalidAttempts.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" + ) + } + } + + private fun validateImportData(importData: ClimbDataBackup) { + if (importData.gyms.isEmpty()) { + throw Exception("Import data is invalid: no gyms found") + } + + if (importData.version.isBlank()) { + throw Exception("Import data is invalid: no version information") + } + + // Check for reasonable data sizes to prevent malicious imports + if (importData.gyms.size > 1000 || + importData.problems.size > 10000 || + importData.sessions.size > 10000 || + importData.attempts.size > 100000 + ) { + throw Exception("Import data is too large: possible corruption or malicious file") + } + } + + suspend fun resetAllData() { + try { + // Clear all data from database + attemptDao.deleteAllAttempts() + sessionDao.deleteAllSessions() + problemDao.deleteAllProblems() + gymDao.deleteAllGyms() + + // Clear all images from storage + clearAllImages() + } catch (e: Exception) { + throw Exception("Reset failed: ${e.message}") + } + } + + private fun clearAllImages() { + try { + // Get the images directory + val imagesDir = File(context.filesDir, "images") + if (imagesDir.exists() && imagesDir.isDirectory) { + val deletedCount = imagesDir.listFiles()?.size ?: 0 + imagesDir.deleteRecursively() + android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") + } + } catch (e: Exception) { + android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") + } + } +} diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index f8e7793..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 = 9; + 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.0.3; + 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 = 9; + 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.0.3; + 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 = 9; + 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.0.3; + 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 = 9; + 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.0.3; + 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 6acfc54..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 d99571e..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() @@ -100,7 +102,9 @@ struct ContentView: View { print("📱 App did become active - checking Live Activity status") Task { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds - dataManager.onAppBecomeActive() + 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 new file mode 100644 index 0000000..a0dcb3c --- /dev/null +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -0,0 +1,447 @@ +// +// BackupFormat.swift + +import Foundation + +// MARK: - Backup Format Specification v2.0 +// Platform-neutral backup format for cross-platform compatibility +// This format ensures portability between iOS and Android while maintaining +// platform-specific implementations + +/// Root structure for OpenClimb backup data +struct ClimbDataBackup: Codable { + let exportedAt: String + let version: String + let formatVersion: String + let gyms: [BackupGym] + let problems: [BackupProblem] + let sessions: [BackupClimbSession] + let attempts: [BackupAttempt] + + init( + exportedAt: String, + version: String = "2.0", + formatVersion: String = "2.0", + gyms: [BackupGym], + problems: [BackupProblem], + sessions: [BackupClimbSession], + attempts: [BackupAttempt] + ) { + self.exportedAt = exportedAt + self.version = version + self.formatVersion = formatVersion + self.gyms = gyms + self.problems = problems + self.sessions = sessions + self.attempts = attempts + } +} + +/// Platform-neutral gym representation for backup/restore +struct BackupGym: Codable { + let id: String + let name: String + let location: String? + let supportedClimbTypes: [ClimbType] + let difficultySystems: [DifficultySystem] + let customDifficultyGrades: [String] + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Gym model + init(from gym: Gym) { + self.id = gym.id.uuidString + self.name = gym.name + self.location = gym.location + self.supportedClimbTypes = gym.supportedClimbTypes + self.difficultySystems = gym.difficultySystems + self.customDifficultyGrades = gym.customDifficultyGrades + self.notes = gym.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.createdAt = formatter.string(from: gym.createdAt) + self.updatedAt = formatter.string(from: gym.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + name: String, + location: String?, + supportedClimbTypes: [ClimbType], + difficultySystems: [DifficultySystem], + customDifficultyGrades: [String] = [], + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.name = name + self.location = location + self.supportedClimbTypes = supportedClimbTypes + self.difficultySystems = difficultySystems + self.customDifficultyGrades = customDifficultyGrades + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Gym model + func toGym() throws -> Gym { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + return Gym.fromImport( + id: uuid, + name: name, + location: location, + supportedClimbTypes: supportedClimbTypes, + difficultySystems: difficultySystems, + customDifficultyGrades: customDifficultyGrades, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral problem representation for backup/restore +struct BackupProblem: Codable { + let id: String + let gymId: String + let name: String? + let description: String? + let climbType: ClimbType + let difficulty: DifficultyGrade + let tags: [String] + let location: String? + let imagePaths: [String]? + let isActive: Bool + let dateSet: String? // ISO 8601 format + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Problem model + init(from problem: Problem) { + self.id = problem.id.uuidString + self.gymId = problem.gymId.uuidString + self.name = problem.name + self.description = problem.description + self.climbType = problem.climbType + self.difficulty = problem.difficulty + self.tags = problem.tags + self.location = problem.location + self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths + self.isActive = problem.isActive + self.notes = problem.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.dateSet = problem.dateSet.map { formatter.string(from: $0) } + self.createdAt = formatter.string(from: problem.createdAt) + self.updatedAt = formatter.string(from: problem.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + name: String?, + description: String?, + climbType: ClimbType, + difficulty: DifficultyGrade, + tags: [String] = [], + location: String?, + imagePaths: [String]?, + isActive: Bool, + dateSet: String?, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.name = name + self.description = description + self.climbType = climbType + self.difficulty = difficulty + self.tags = tags + self.location = location + self.imagePaths = imagePaths + self.isActive = isActive + self.dateSet = dateSet + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Problem model + func toProblem() throws -> Problem { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let dateSetDate = dateSet.flatMap { formatter.date(from: $0) } + + return Problem.fromImport( + id: uuid, + gymId: gymUuid, + name: name, + description: description, + climbType: climbType, + difficulty: difficulty, + tags: tags, + location: location, + imagePaths: imagePaths ?? [], + isActive: isActive, + dateSet: dateSetDate, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } + + /// Create a copy with updated image paths for import processing + func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem { + return BackupProblem( + id: self.id, + gymId: self.gymId, + name: self.name, + description: self.description, + climbType: self.climbType, + difficulty: self.difficulty, + tags: self.tags, + location: self.location, + imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, + isActive: self.isActive, + dateSet: self.dateSet, + notes: self.notes, + createdAt: self.createdAt, + updatedAt: self.updatedAt + ) + } +} + +/// Platform-neutral climb session representation for backup/restore +struct BackupClimbSession: Codable { + let id: String + let gymId: String + let date: String // ISO 8601 format + let startTime: String? // ISO 8601 format + let endTime: String? // ISO 8601 format + let duration: Int64? // Duration in seconds + let status: SessionStatus + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS ClimbSession model + init(from session: ClimbSession) { + self.id = session.id.uuidString + self.gymId = session.gymId.uuidString + self.status = session.status + self.notes = session.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.date = formatter.string(from: session.date) + self.startTime = session.startTime.map { formatter.string(from: $0) } + self.endTime = session.endTime.map { formatter.string(from: $0) } + self.duration = session.duration.map { Int64($0) } + self.createdAt = formatter.string(from: session.createdAt) + self.updatedAt = formatter.string(from: session.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + date: String, + startTime: String?, + endTime: String?, + duration: Int64?, + status: SessionStatus, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.date = date + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.status = status + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS ClimbSession model + func toClimbSession() throws -> ClimbSession { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let dateValue = formatter.date(from: date), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let startTimeValue = startTime.flatMap { formatter.date(from: $0) } + let endTimeValue = endTime.flatMap { formatter.date(from: $0) } + let durationValue = duration.map { Int($0) } + + return ClimbSession.fromImport( + id: uuid, + gymId: gymUuid, + date: dateValue, + startTime: startTimeValue, + endTime: endTimeValue, + duration: durationValue, + status: status, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral attempt representation for backup/restore +struct BackupAttempt: Codable { + let id: String + let sessionId: String + let problemId: String + let result: AttemptResult + let highestHold: String? + let notes: String? + let duration: Int64? // Duration in seconds + let restTime: Int64? // Rest time in seconds + let timestamp: String // ISO 8601 format + let createdAt: String // ISO 8601 format + + /// Initialize from native iOS Attempt model + init(from attempt: Attempt) { + self.id = attempt.id.uuidString + self.sessionId = attempt.sessionId.uuidString + self.problemId = attempt.problemId.uuidString + self.result = attempt.result + self.highestHold = attempt.highestHold + self.notes = attempt.notes + self.duration = attempt.duration.map { Int64($0) } + self.restTime = attempt.restTime.map { Int64($0) } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.timestamp = formatter.string(from: attempt.timestamp) + self.createdAt = formatter.string(from: attempt.createdAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String?, + notes: String?, + duration: Int64?, + restTime: Int64?, + timestamp: String, + createdAt: String + ) { + self.id = id + self.sessionId = sessionId + self.problemId = problemId + self.result = result + self.highestHold = highestHold + self.notes = notes + self.duration = duration + self.restTime = restTime + self.timestamp = timestamp + self.createdAt = createdAt + } + + /// Convert to native iOS Attempt model + func toAttempt() throws -> Attempt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let sessionUuid = UUID(uuidString: sessionId), + let problemUuid = UUID(uuidString: problemId), + let timestampDate = formatter.date(from: timestamp), + let createdDate = formatter.date(from: createdAt) + else { + throw BackupError.invalidDateFormat + } + + let durationValue = duration.map { Int($0) } + let restTimeValue = restTime.map { Int($0) } + + return Attempt.fromImport( + id: uuid, + sessionId: sessionUuid, + problemId: problemUuid, + result: result, + highestHold: highestHold, + notes: notes, + duration: durationValue, + restTime: restTimeValue, + timestamp: timestampDate, + createdAt: createdDate + ) + } +} + +// MARK: - Backup Format Errors + +enum BackupError: LocalizedError { + case invalidDateFormat + case invalidUUID + case missingRequiredField(String) + case unsupportedFormatVersion(String) + + var errorDescription: String? { + switch self { + case .invalidDateFormat: + return "Invalid date format in backup data" + case .invalidUUID: + return "Invalid UUID format in backup data" + case .missingRequiredField(let field): + return "Missing required field: \(field)" + case .unsupportedFormatVersion(let version): + return "Unsupported backup format version: \(version)" + } + } +} + +// MARK: - Extensions + +// MARK: - Helper Extensions for Optional Mapping + +extension Optional { + func map(_ transform: (Wrapped) -> T) -> T? { + return self.flatMap { .some(transform($0)) } + } +} 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/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index ebdbd26..64d4fbd 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,4 +1,3 @@ - import Compression import Foundation import zlib @@ -10,7 +9,7 @@ struct ZipUtils { private static let METADATA_FILENAME = "metadata.txt" static func createExportZip( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) throws -> Data { @@ -196,7 +195,7 @@ struct ZipUtils { } private static func createMetadata( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) -> String { return """ diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 112f549..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() } @@ -473,13 +499,14 @@ class ClimbingDataManager: ObservableObject { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - let exportData = ClimbDataExport( + let exportData = ClimbDataBackup( exportedAt: dateFormatter.string(from: Date()), version: "2.0", - gyms: gyms.map { AndroidGym(from: $0) }, - problems: problems.map { AndroidProblem(from: $0) }, - sessions: sessions.map { AndroidClimbSession(from: $0) }, - attempts: attempts.map { AndroidAttempt(from: $0) } + formatVersion: "2.0", + gyms: gyms.map { BackupGym(from: $0) }, + problems: problems.map { BackupProblem(from: $0) }, + sessions: sessions.map { BackupClimbSession(from: $0) }, + attempts: attempts.map { BackupAttempt(from: $0) } ) // Collect referenced image paths @@ -529,7 +556,7 @@ class ClimbingDataManager: ObservableObject { print("Raw JSON content preview:") print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") - let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData) + let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) print("Successfully decoded import data:") print("- Gyms: \(importData.gyms.count)") @@ -546,16 +573,19 @@ class ClimbingDataManager: ObservableObject { imagePathMapping: importResult.imagePathMapping ) - self.gyms = importData.gyms.map { $0.toGym() } - self.problems = updatedProblems.map { $0.toProblem() } - self.sessions = importData.sessions.map { $0.toClimbSession() } - self.attempts = importData.attempts.map { $0.toAttempt() } + self.gyms = try importData.gyms.map { try $0.toGym() } + self.problems = try updatedProblems.map { try $0.toProblem() } + self.sessions = try importData.sessions.map { try $0.toClimbSession() } + self.attempts = try importData.attempts.map { try $0.toAttempt() } saveGyms() saveProblems() 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() @@ -584,337 +614,6 @@ class ClimbingDataManager: ObservableObject { } } -struct ClimbDataExport: Codable { - let exportedAt: String - let version: String - let gyms: [AndroidGym] - let problems: [AndroidProblem] - let sessions: [AndroidClimbSession] - let attempts: [AndroidAttempt] - - init( - exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem], - sessions: [AndroidClimbSession], attempts: [AndroidAttempt] - ) { - self.exportedAt = exportedAt - self.version = version - self.gyms = gyms - self.problems = problems - self.sessions = sessions - self.attempts = attempts - } -} - -struct AndroidGym: Codable { - let id: String - let name: String - let location: String? - let supportedClimbTypes: [ClimbType] - let difficultySystems: [DifficultySystem] - let customDifficultyGrades: [String] - let notes: String? - let createdAt: String - let updatedAt: String - - init(from gym: Gym) { - self.id = gym.id.uuidString - self.name = gym.name - self.location = gym.location - self.supportedClimbTypes = gym.supportedClimbTypes - self.difficultySystems = gym.difficultySystems - self.customDifficultyGrades = gym.customDifficultyGrades - self.notes = gym.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.createdAt = formatter.string(from: gym.createdAt) - self.updatedAt = formatter.string(from: gym.updatedAt) - } - - init( - id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], - difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [], - notes: String?, createdAt: String, updatedAt: String - ) { - self.id = id - self.name = name - self.location = location - self.supportedClimbTypes = supportedClimbTypes - self.difficultySystems = difficultySystems - self.customDifficultyGrades = customDifficultyGrades - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toGym() -> Gym { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let gymId = UUID(uuidString: id) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Gym.fromImport( - id: gymId, - name: name, - location: location, - supportedClimbTypes: supportedClimbTypes, - difficultySystems: difficultySystems, - customDifficultyGrades: customDifficultyGrades, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidProblem: Codable { - let id: String - let gymId: String - let name: String? - let description: String? - let climbType: ClimbType - let difficulty: DifficultyGrade - let tags: [String] - let location: String? - let imagePaths: [String]? - let isActive: Bool - let dateSet: String? - let notes: String? - let createdAt: String - let updatedAt: String - - init(from problem: Problem) { - self.id = problem.id.uuidString - self.gymId = problem.gymId.uuidString - self.name = problem.name - self.description = problem.description - self.climbType = problem.climbType - self.difficulty = problem.difficulty - self.tags = problem.tags - self.location = problem.location - self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths - self.isActive = problem.isActive - self.notes = problem.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil - self.createdAt = formatter.string(from: problem.createdAt) - self.updatedAt = formatter.string(from: problem.updatedAt) - } - - init( - id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, tags: [String] = [], - location: String? = nil, - imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil, - notes: String? = nil, - createdAt: String, updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.name = name - self.description = description - self.climbType = climbType - self.difficulty = difficulty - self.tags = tags - self.location = location - self.imagePaths = imagePaths - self.isActive = isActive - self.dateSet = dateSet - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toProblem() -> Problem { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let problemId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Problem.fromImport( - id: problemId, - gymId: preservedGymId, - name: name, - description: description, - climbType: climbType, - difficulty: difficulty, - tags: tags, - location: location, - imagePaths: imagePaths ?? [], - isActive: isActive, - dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } - - func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem { - return AndroidProblem( - id: self.id, - gymId: self.gymId, - name: self.name, - description: self.description, - climbType: self.climbType, - difficulty: self.difficulty, - tags: self.tags, - location: self.location, - imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, - isActive: self.isActive, - dateSet: self.dateSet, - notes: self.notes, - createdAt: self.createdAt, - updatedAt: self.updatedAt - ) - } -} - -struct AndroidClimbSession: Codable { - let id: String - let gymId: String - let date: String - let startTime: String? - let endTime: String? - let duration: Int64? - let status: SessionStatus - let notes: String? - let createdAt: String - let updatedAt: String - - init(from session: ClimbSession) { - self.id = session.id.uuidString - self.gymId = session.gymId.uuidString - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.date = formatter.string(from: session.date) - self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil - self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil - self.duration = session.duration != nil ? Int64(session.duration!) : nil - self.status = session.status - self.notes = session.notes - self.createdAt = formatter.string(from: session.createdAt) - self.updatedAt = formatter.string(from: session.updatedAt) - } - - init( - id: String, gymId: String, date: String, startTime: String?, endTime: String?, - duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String, - updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.date = date - self.startTime = startTime - self.endTime = endTime - self.duration = duration - self.status = status - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toClimbSession() -> ClimbSession { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - // Preserve original IDs and dates - let sessionId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let sessionDate = formatter.date(from: date) ?? Date() - let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil - let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return ClimbSession.fromImport( - id: sessionId, - gymId: preservedGymId, - date: sessionDate, - startTime: sessionStartTime, - endTime: sessionEndTime, - duration: duration != nil ? Int(duration!) : nil, - status: status, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidAttempt: Codable { - let id: String - let sessionId: String - let problemId: String - let result: AttemptResult - let highestHold: String? - let notes: String? - let duration: Int64? - let restTime: Int64? - let timestamp: String - let createdAt: String - - init(from attempt: Attempt) { - self.id = attempt.id.uuidString - self.sessionId = attempt.sessionId.uuidString - self.problemId = attempt.problemId.uuidString - self.result = attempt.result - self.highestHold = attempt.highestHold - self.notes = attempt.notes - self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil - self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.timestamp = formatter.string(from: attempt.timestamp) - self.createdAt = formatter.string(from: attempt.createdAt) - } - - init( - id: String, sessionId: String, problemId: String, result: AttemptResult, - highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?, - timestamp: String, createdAt: String - ) { - self.id = id - self.sessionId = sessionId - self.problemId = problemId - self.result = result - self.highestHold = highestHold - self.notes = notes - self.duration = duration - self.restTime = restTime - self.timestamp = timestamp - self.createdAt = createdAt - } - - func toAttempt() -> Attempt { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let attemptId = UUID(uuidString: id) ?? UUID() - let preservedSessionId = UUID(uuidString: sessionId) ?? UUID() - let preservedProblemId = UUID(uuidString: problemId) ?? UUID() - let attemptTimestamp = formatter.date(from: timestamp) ?? Date() - let createdDate = formatter.date(from: createdAt) ?? Date() - - return Attempt.fromImport( - id: attemptId, - sessionId: preservedSessionId, - problemId: preservedProblemId, - result: result, - highestHold: highestHold, - notes: notes, - duration: duration != nil ? Int(duration!) : nil, - restTime: restTime != nil ? Int(restTime!) : nil, - timestamp: attemptTimestamp, - createdAt: createdDate - ) - } -} - extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -949,9 +648,9 @@ extension ClimbingDataManager { } private func updateProblemImagePaths( - problems: [AndroidProblem], + problems: [BackupProblem], imagePathMapping: [String: String] - ) -> [AndroidProblem] { + ) -> [BackupProblem] { return problems.map { problem in let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in let fileName = URL(fileURLWithPath: oldPath).lastPathComponent @@ -1298,7 +997,7 @@ extension ClimbingDataManager { saveAttempts() } - private func validateImportData(_ importData: ClimbDataExport) throws { + private func validateImportData(_ importData: ClimbDataBackup) throws { if importData.gyms.isEmpty { throw NSError( domain: "ImportError", code: 1, diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index c6e7992..4f68042 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -130,14 +130,8 @@ final class LiveActivityManager { completedProblems: completedProblems ) - do { - await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("✅ Live Activity updated successfully") - } catch { - print("❌ Failed to update Live Activity: \(error)") - // If update fails, the activity might have been dismissed - self.currentActivity = nil - } + await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) + print("✅ Live Activity updated successfully") } /// Call this when a ClimbSession ends to end the Live Activity diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index a9df99b..0d759d3 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -168,15 +168,9 @@ struct ActiveSessionBanner: View { .onDisappear { stopTimer() } - .background( - NavigationLink( - destination: SessionDetailView(sessionId: session.id), - isActive: $navigateToDetail - ) { - EmptyView() - } - .hidden() - ) + .navigationDestination(isPresented: $navigateToDetail) { + SessionDetailView(sessionId: session.id) + } } private func formatDuration(from start: Date, to end: Date) -> String { 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/.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/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