1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import

formats :)
This commit is contained in:
2025-09-28 02:37:03 -06:00
parent cf2e2f7c57
commit c3f847e1e6
48 changed files with 6944 additions and 1107 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -8,6 +8,9 @@
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Permission for sync functionality -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Hardware features -->
<uses-feature android:name="android.hardware.camera" android:required="false" />

View File

@@ -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<BackupGym>,
val problems: List<BackupProblem>,
val sessions: List<BackupClimbSession>,
val attempts: List<BackupAttempt>
)
/** Platform-neutral gym representation for backup/restore */
@Serializable
data class BackupGym(
val id: String,
val name: String,
val location: String? = null,
val supportedClimbTypes: List<ClimbType>,
val difficultySystems: List<DifficultySystem>,
@kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = 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<String> = emptyList(),
val location: String? = null,
val imagePaths: List<String>? = 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<String>): 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
)
}
}

View File

@@ -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<String, String>()
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<String>()
val invalidImages = mutableListOf<String>()
val missingImages = mutableListOf<String>()
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<String>
): Map<String, String> {
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<String, String>
) : ImageMigrationResult()
data class Failed(val error: String) : ImageMigrationResult()
}
/** Result of image naming validation */
data class ValidationResult(
val totalImages: Int,
val validImages: List<String>,
val invalidImages: List<String>,
val missingImages: List<String>
) {
val isAllValid: Boolean
get() = invalidImages.isEmpty() && missingImages.isEmpty()
val validPercentage: Double
get() = if (totalImages == 0) 100.0 else (validImages.size.toDouble() / totalImages) * 100
}

View File

@@ -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
)
}
}

View File

@@ -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()
)
}
}

View File

@@ -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<String> = 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<String> =
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<DifficultySystem> = 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<DifficultySystem> =
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)

View File

@@ -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<ClimbType>,
val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = 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<ClimbType>,
val difficultySystems: List<DifficultySystem>,
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String,
val updatedAt: String
) {
companion object {
fun create(
name: String,
location: String? = null,
supportedClimbTypes: List<ClimbType>,
difficultySystems: List<DifficultySystem>,
customDifficultyGrades: List<String> = emptyList(),
notes: String? = null
name: String,
location: String? = null,
supportedClimbTypes: List<ClimbType>,
difficultySystems: List<DifficultySystem>,
customDifficultyGrades: List<String> = 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
)
}
}

View File

@@ -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,

View File

@@ -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<List<Gym>> = 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<List<Gym>> = 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<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = 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<List<Problem>> = 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<List<ClimbSession>> = 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<ClimbSession?> = 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<List<Attempt>> =
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<ClimbDataExport>(importResult.jsonContent)
json.decodeFromString<ClimbDataBackup>(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<Gym>,
problems: List<Problem>,
@@ -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<Gym>,
val problems: List<Problem>,
val sessions: List<ClimbSession>,
val attempts: List<Attempt>
)

View File

@@ -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()})"
}
}

View File

@@ -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<Boolean> = _isSyncing.asStateFlow()
private val _lastSyncTime = MutableStateFlow<String?>(null)
val lastSyncTime: StateFlow<String?> = _lastSyncTime.asStateFlow()
private val _syncError = MutableStateFlow<String?>(null)
val syncError: StateFlow<String?> = _syncError.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _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<ClimbDataBackup>(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<ClimbDataBackup>(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<String, String> {
val imagePathMapping = mutableMapOf<String, String>()
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<String, String> = 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<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// 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<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// 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<String>()
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<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// 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<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// 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")
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ClimbViewModel::class.java)) {
return ClimbViewModel(repository) as T
return ClimbViewModel(repository, syncService) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}

View File

@@ -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))
}
}

View File

@@ -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<String>
): Map<String, String> {
val renameMap = mutableMapOf<String, String>()
existingFilenames.forEachIndexed { index, oldFilename ->
val newFilename = migrateFilename(oldFilename, problemId, index)
if (newFilename != oldFilename) {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
}

View File

@@ -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<String>
): Map<String, String> {
val migrationMap = mutableMapOf<String, String>()
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<String, List<String>>
): Map<String, String> {
val allMigrations = mutableMapOf<String, String>()
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()
}

View File

@@ -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<String>,
directory: File? = null
context: Context,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>,
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<String>
context: Context,
uri: android.net.Uri,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) {
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<String>
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
): 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<String, String> // original filename -> new relative path
val jsonContent: String,
val importedImagePaths: Map<String, String> // 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<String, String>()
var foundRequiredFiles = mutableSetOf<String>()
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<com.atridad.openclimb.data.model.Problem>,
imagePathMapping: Map<String, String>
): List<com.atridad.openclimb.data.model.Problem> {
problems: List<BackupProblem>,
imagePathMapping: Map<String, String>
): List<BackupProblem> {
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)
}
}
}

View File

@@ -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<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// 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<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// 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<String>()
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<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// 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<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// 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
}
}
}

View File

@@ -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]

Binary file not shown.

View File

@@ -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<List<Gym>> = 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<List<Gym>> = gymDao.searchGyms(query)
// Problem operations
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = 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<List<Problem>> = problemDao.searchProblems(query)
// Session operations
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
sessionDao.getSessionsByGym(gymId)
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
fun getActiveSessionFlow(): Flow<ClimbSession?> = 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<List<Attempt>> = attemptDao.getAllAttempts()
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
attemptDao.getAttemptsBySession(sessionId)
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
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<ClimbDataBackup>(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<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// 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}")
}
}
}