Compare commits

...

6 Commits

Author SHA1 Message Date
d570b8a70c Merge pull request 'Massive Sync Update!' (#5) from sync-server into main
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 1m26s
Reviewed-on: atridad/OpenClimb#5
2025-09-29 05:35:12 +00:00
c676e25a3d Sync server name change
Some checks failed
OpenClimb Docker Deploy / build-and-push (pull_request) Has been cancelled
2025-09-28 23:34:37 -06:00
56c501cef6 Version bumps 2025-09-28 23:33:05 -06:00
f7f1fba9aa Added proper CI
All checks were successful
OpenClimb Docker Deploy / build-and-push (pull_request) Successful in 2m3s
2025-09-28 23:29:44 -06:00
6e490d1598 Sync Server DONE! 2025-09-28 23:12:46 -06:00
036becb5be 1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
2025-09-28 02:37:03 -06:00
49 changed files with 7266 additions and 1105 deletions

43
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: OpenClimb Docker Deploy
on:
push:
branches: [main]
paths:
- "sync/**"
- ".github/workflows/deploy.yml"
pull_request:
branches: [main]
paths:
- "sync/**"
- ".github/workflows/deploy.yml"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push sync-server
uses: docker/build-push-action@v4
with:
context: ./sync
file: ./sync/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/openclimb-sync:latest

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

View File

@@ -324,6 +324,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -381,6 +382,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@@ -394,7 +396,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -414,7 +416,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -437,7 +439,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -457,7 +459,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -479,7 +481,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -490,7 +492,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -509,7 +511,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -520,7 +522,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.3;
MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D24C19672E75002A0045894C"
BuildableName = "OpenClimb.app"
BlueprintName = "OpenClimb"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -57,6 +57,8 @@ struct ContentView: View {
}
.onAppear {
setupNotificationObservers()
// Trigger auto-sync on app launch
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
.onDisappear {
removeNotificationObservers()
@@ -100,7 +102,9 @@ struct ContentView: View {
print("📱 App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
dataManager.onAppBecomeActive()
await dataManager.onAppBecomeActive()
// Trigger auto-sync when app becomes active
await dataManager.syncService.triggerAutoSync(dataManager: dataManager)
}
}

View File

@@ -0,0 +1,447 @@
//
// BackupFormat.swift
import Foundation
// MARK: - Backup Format Specification v2.0
// Platform-neutral backup format for cross-platform compatibility
// This format ensures portability between iOS and Android while maintaining
// platform-specific implementations
/// Root structure for OpenClimb backup data
struct ClimbDataBackup: Codable {
let exportedAt: String
let version: String
let formatVersion: String
let gyms: [BackupGym]
let problems: [BackupProblem]
let sessions: [BackupClimbSession]
let attempts: [BackupAttempt]
init(
exportedAt: String,
version: String = "2.0",
formatVersion: String = "2.0",
gyms: [BackupGym],
problems: [BackupProblem],
sessions: [BackupClimbSession],
attempts: [BackupAttempt]
) {
self.exportedAt = exportedAt
self.version = version
self.formatVersion = formatVersion
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
}
}
/// Platform-neutral gym representation for backup/restore
struct BackupGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS Gym model
init(from gym: Gym) {
self.id = gym.id.uuidString
self.name = gym.name
self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems
self.customDifficultyGrades = gym.customDifficultyGrades
self.notes = gym.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
name: String,
location: String?,
supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem],
customDifficultyGrades: [String] = [],
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS Gym model
func toGym() throws -> Gym {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
return Gym.fromImport(
id: uuid,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
/// Platform-neutral problem representation for backup/restore
struct BackupProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let tags: [String]
let location: String?
let imagePaths: [String]?
let isActive: Bool
let dateSet: String? // ISO 8601 format
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS Problem model
init(from problem: Problem) {
self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString
self.name = problem.name
self.description = problem.description
self.climbType = problem.climbType
self.difficulty = problem.difficulty
self.tags = problem.tags
self.location = problem.location
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
self.isActive = problem.isActive
self.notes = problem.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.dateSet = problem.dateSet.map { formatter.string(from: $0) }
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
name: String?,
description: String?,
climbType: ClimbType,
difficulty: DifficultyGrade,
tags: [String] = [],
location: String?,
imagePaths: [String]?,
isActive: Bool,
dateSet: String?,
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS Problem model
func toProblem() throws -> Problem {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let dateSetDate = dateSet.flatMap { formatter.date(from: $0) }
return Problem.fromImport(
id: uuid,
gymId: gymUuid,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
tags: tags,
location: location,
imagePaths: imagePaths ?? [],
isActive: isActive,
dateSet: dateSetDate,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
/// Create a copy with updated image paths for import processing
func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem {
return BackupProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
tags: self.tags,
location: self.location,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
isActive: self.isActive,
dateSet: self.dateSet,
notes: self.notes,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
/// Platform-neutral climb session representation for backup/restore
struct BackupClimbSession: Codable {
let id: String
let gymId: String
let date: String // ISO 8601 format
let startTime: String? // ISO 8601 format
let endTime: String? // ISO 8601 format
let duration: Int64? // Duration in seconds
let status: SessionStatus
let notes: String?
let createdAt: String // ISO 8601 format
let updatedAt: String // ISO 8601 format
/// Initialize from native iOS ClimbSession model
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
self.status = session.status
self.notes = session.notes
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.date = formatter.string(from: session.date)
self.startTime = session.startTime.map { formatter.string(from: $0) }
self.endTime = session.endTime.map { formatter.string(from: $0) }
self.duration = session.duration.map { Int64($0) }
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
gymId: String,
date: String,
startTime: String?,
endTime: String?,
duration: Int64?,
status: SessionStatus,
notes: String?,
createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Convert to native iOS ClimbSession model
func toClimbSession() throws -> ClimbSession {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let gymUuid = UUID(uuidString: gymId),
let dateValue = formatter.date(from: date),
let createdDate = formatter.date(from: createdAt),
let updatedDate = formatter.date(from: updatedAt)
else {
throw BackupError.invalidDateFormat
}
let startTimeValue = startTime.flatMap { formatter.date(from: $0) }
let endTimeValue = endTime.flatMap { formatter.date(from: $0) }
let durationValue = duration.map { Int($0) }
return ClimbSession.fromImport(
id: uuid,
gymId: gymUuid,
date: dateValue,
startTime: startTimeValue,
endTime: endTimeValue,
duration: durationValue,
status: status,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
/// Platform-neutral attempt representation for backup/restore
struct BackupAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int64? // Duration in seconds
let restTime: Int64? // Rest time in seconds
let timestamp: String // ISO 8601 format
let createdAt: String // ISO 8601 format
/// Initialize from native iOS Attempt model
init(from attempt: Attempt) {
self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString
self.problemId = attempt.problemId.uuidString
self.result = attempt.result
self.highestHold = attempt.highestHold
self.notes = attempt.notes
self.duration = attempt.duration.map { Int64($0) }
self.restTime = attempt.restTime.map { Int64($0) }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
}
/// Initialize with explicit parameters for import
init(
id: String,
sessionId: String,
problemId: String,
result: AttemptResult,
highestHold: String?,
notes: String?,
duration: Int64?,
restTime: Int64?,
timestamp: String,
createdAt: String
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
/// Convert to native iOS Attempt model
func toAttempt() throws -> Attempt {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let uuid = UUID(uuidString: id),
let sessionUuid = UUID(uuidString: sessionId),
let problemUuid = UUID(uuidString: problemId),
let timestampDate = formatter.date(from: timestamp),
let createdDate = formatter.date(from: createdAt)
else {
throw BackupError.invalidDateFormat
}
let durationValue = duration.map { Int($0) }
let restTimeValue = restTime.map { Int($0) }
return Attempt.fromImport(
id: uuid,
sessionId: sessionUuid,
problemId: problemUuid,
result: result,
highestHold: highestHold,
notes: notes,
duration: durationValue,
restTime: restTimeValue,
timestamp: timestampDate,
createdAt: createdDate
)
}
}
// MARK: - Backup Format Errors
enum BackupError: LocalizedError {
case invalidDateFormat
case invalidUUID
case missingRequiredField(String)
case unsupportedFormatVersion(String)
var errorDescription: String? {
switch self {
case .invalidDateFormat:
return "Invalid date format in backup data"
case .invalidUUID:
return "Invalid UUID format in backup data"
case .missingRequiredField(let field):
return "Missing required field: \(field)"
case .unsupportedFormatVersion(let version):
return "Unsupported backup format version: \(version)"
}
}
}
// MARK: - Extensions
// MARK: - Helper Extensions for Optional Mapping
extension Optional {
func map<T>(_ transform: (Wrapped) -> T) -> T? {
return self.flatMap { .some(transform($0)) }
}
}

View File

@@ -0,0 +1,978 @@
import Combine
import Foundation
import UIKit
@MainActor
class SyncService: ObservableObject {
@Published var isSyncing = false
@Published var lastSyncTime: Date?
@Published var syncError: String?
@Published var isConnected = false
@Published var isTesting = false
private let userDefaults = UserDefaults.standard
private enum Keys {
static let serverURL = "sync_server_url"
static let authToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time"
static let isConnected = "sync_is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
}
var serverURL: String {
get { userDefaults.string(forKey: Keys.serverURL) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.serverURL) }
}
var authToken: String {
get { userDefaults.string(forKey: Keys.authToken) ?? "" }
set { userDefaults.set(newValue, forKey: Keys.authToken) }
}
var isConfigured: Bool {
return !serverURL.isEmpty && !authToken.isEmpty
}
var isAutoSyncEnabled: Bool {
get { userDefaults.bool(forKey: Keys.autoSyncEnabled) }
set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) }
}
init() {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
}
func downloadData() async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return backup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/sync") else {
throw SyncError.invalidURL
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(backup)
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = jsonData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
case 400:
throw SyncError.badRequest
default:
throw SyncError.serverError(httpResponse.statusCode)
}
do {
let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data)
return responseBackup
} catch {
throw SyncError.decodingError(error)
}
}
func uploadImage(filename: String, imageData: Data) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.httpBody = imageData
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
break
case 401:
throw SyncError.unauthorized
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func downloadImage(filename: String) async throws -> Data {
guard isConfigured else {
throw SyncError.notConfigured
}
guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
return data
case 401:
throw SyncError.unauthorized
case 404:
throw SyncError.imageNotFound
default:
throw SyncError.serverError(httpResponse.statusCode)
}
}
func syncWithServer(dataManager: ClimbingDataManager) async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
guard isConnected else {
throw SyncError.notConnected
}
isSyncing = true
syncError = nil
defer {
isSyncing = false
}
do {
// Get local backup data
let localBackup = createBackupFromDataManager(dataManager)
// Download server data
let serverBackup = try await downloadData()
// Check if we have any local data
let hasLocalData =
!dataManager.gyms.isEmpty || !dataManager.problems.isEmpty
|| !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty
let hasServerData =
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
print("Importing data after images...")
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Full restore completed")
} else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server
print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
print("Uploading local images to server...")
try await syncImagesToServer(dataManager: dataManager)
print("Initial upload completed")
} else if hasLocalData && hasServerData {
// Case 3: Both have data - compare timestamps (last writer wins)
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("🕐 DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
)
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
print("Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
print(
"🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else {
print("No data to sync")
}
// Update last sync time
lastSyncTime = Date()
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
} catch {
syncError = error.localizedDescription
throw error
}
}
/// Parses ISO8601 timestamp to milliseconds for comparison
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: timestamp) {
return Int64(date.timeIntervalSince1970 * 1000)
}
print("Failed to parse timestamp: \(timestamp), using 0")
return 0
}
private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager)
async throws -> [String: String]
{
var imagePathMapping: [String: String] = [:]
// Process images by problem to maintain consistent naming
for problem in backup.problems {
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
for (index, imagePath) in imagePaths.enumerated() {
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
do {
let imageData = try await downloadImage(filename: serverFilename)
// Generate consistent filename if needed
let consistentFilename =
ImageNamingUtils.isValidImageFilename(serverFilename)
? serverFilename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id, imageIndex: index)
// Save image with consistent filename
let imageManager = ImageManager.shared
_ = try imageManager.saveImportedImage(
imageData, filename: consistentFilename)
// Map server filename to consistent local filename
imagePathMapping[serverFilename] = consistentFilename
print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)")
} catch SyncError.imageNotFound {
print("Image not found on server: \(serverFilename)")
continue
} catch {
print("Failed to download image \(serverFilename): \(error)")
continue
}
}
}
return imagePathMapping
}
private func syncImagesToServer(dataManager: ClimbingDataManager) async throws {
// Process images by problem to ensure consistent naming
for problem in dataManager.problems {
guard !problem.imagePaths.isEmpty else { continue }
for (index, imagePath) in problem.imagePaths.enumerated() {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
// Ensure filename follows consistent naming convention
let consistentFilename =
ImageNamingUtils.isValidImageFilename(filename)
? filename
: ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: index)
// Load image data
let imageManager = ImageManager.shared
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
do {
// If filename changed, rename local file
if filename != consistentFilename {
let newPath = imageManager.imagesDirectory.appendingPathComponent(
consistentFilename
).path
do {
try FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
print("Renamed local image: \(filename) -> \(consistentFilename)")
// Update problem's image path in memory for consistency
// Note: This would require updating the problem in the data manager
} catch {
print("Failed to rename local image, using original: \(error)")
}
}
try await uploadImage(filename: consistentFilename, imageData: imageData)
print("Successfully uploaded image: \(consistentFilename)")
} catch {
print("Failed to upload image \(consistentFilename): \(error)")
// Continue with other images even if one fails
}
}
}
}
}
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup
{
return ClimbDataBackup(
exportedAt: DataStateManager.shared.getLastModified(),
gyms: dataManager.gyms.map { BackupGym(from: $0) },
problems: dataManager.problems.map { BackupProblem(from: $0) },
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
)
}
private func importBackupToDataManager(
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
imagePathMapping: [String: String] = [:]
) throws {
do {
// Update problem image paths to point to downloaded images
let updatedBackup: ClimbDataBackup
if !imagePathMapping.isEmpty {
let updatedProblems = backup.problems.map { problem in
let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in
imagePathMapping[oldPath] ?? oldPath
}
return BackupProblem(
id: problem.id,
gymId: problem.gymId,
name: problem.name,
description: problem.description,
climbType: problem.climbType,
difficulty: problem.difficulty,
tags: problem.tags,
location: problem.location,
imagePaths: updatedImagePaths,
isActive: problem.isActive,
dateSet: problem.dateSet,
notes: problem.notes,
createdAt: problem.createdAt,
updatedAt: problem.updatedAt
)
}
updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt,
version: backup.version,
formatVersion: backup.formatVersion,
gyms: backup.gyms,
problems: updatedProblems,
sessions: backup.sessions,
attempts: backup.attempts
)
} else {
updatedBackup = backup
}
// Create a minimal ZIP with just the JSON data for existing import mechanism
let zipData = try createMinimalZipFromBackup(updatedBackup)
// Use existing import method which properly handles data restoration
try dataManager.importData(from: zipData)
// Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
} catch {
throw SyncError.importFailed(error)
}
}
private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data {
// Create JSON data
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .custom { date, encoder in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from: date))
}
let jsonData = try encoder.encode(backup)
// Collect all downloaded images from ImageManager
let imageManager = ImageManager.shared
var imageFiles: [(filename: String, data: Data)] = []
let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] })
for imagePath in imagePaths {
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
imageFiles.append((filename: filename, data: imageData))
}
}
// Create ZIP with data.json, metadata, and images
var zipData = Data()
var fileEntries: [(name: String, data: Data, offset: UInt32)] = []
var currentOffset: UInt32 = 0
// Add data.json to ZIP
try addFileToMinimalZip(
filename: "data.json",
fileData: jsonData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add metadata with correct image count
let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)"
let metadataData = metadata.data(using: .utf8) ?? Data()
try addFileToMinimalZip(
filename: "metadata.txt",
fileData: metadataData,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
// Add images to ZIP in images/ directory
for imageFile in imageFiles {
try addFileToMinimalZip(
filename: "images/\(imageFile.filename)",
fileData: imageFile.data,
zipData: &zipData,
fileEntries: &fileEntries,
currentOffset: &currentOffset
)
}
// Add central directory
var centralDirectory = Data()
for entry in fileEntries {
centralDirectory.append(createCentralDirectoryHeader(entry: entry))
}
// Add end of central directory record
let endOfCentralDir = createEndOfCentralDirectoryRecord(
fileCount: UInt16(fileEntries.count),
centralDirSize: UInt32(centralDirectory.count),
centralDirOffset: currentOffset
)
zipData.append(centralDirectory)
zipData.append(endOfCentralDir)
return zipData
}
private func addFileToMinimalZip(
filename: String,
fileData: Data,
zipData: inout Data,
fileEntries: inout [(name: String, data: Data, offset: UInt32)],
currentOffset: inout UInt32
) throws {
let localFileHeader = createLocalFileHeader(
filename: filename, fileSize: UInt32(fileData.count))
fileEntries.append((name: filename, data: fileData, offset: currentOffset))
zipData.append(localFileHeader)
zipData.append(fileData)
currentOffset += UInt32(localFileHeader.count + fileData.count)
}
private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data {
var header = Data()
// Local file header signature
header.append(Data([0x50, 0x4b, 0x03, 0x04]))
// Version needed to extract (2.0)
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method (no compression)
header.append(Data([0x00, 0x00]))
// Last mod file time & date (dummy values)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32 (dummy - we're not compressing)
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = filename.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File name
header.append(filenameData)
return header
}
private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32))
-> Data
{
var header = Data()
// Central directory signature
header.append(Data([0x50, 0x4b, 0x01, 0x02]))
// Version made by
header.append(Data([0x14, 0x00]))
// Version needed to extract
header.append(Data([0x14, 0x00]))
// General purpose bit flag
header.append(Data([0x00, 0x00]))
// Compression method
header.append(Data([0x00, 0x00]))
// Last mod file time & date
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// CRC-32
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Compressed size
let compressedSize = UInt32(entry.data.count)
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// Uncompressed size
withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) }
// File name length
let filenameData = entry.name.data(using: .utf8) ?? Data()
let filenameLength = UInt16(filenameData.count)
withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) }
// Extra field length
header.append(Data([0x00, 0x00]))
// File comment length
header.append(Data([0x00, 0x00]))
// Disk number start
header.append(Data([0x00, 0x00]))
// Internal file attributes
header.append(Data([0x00, 0x00]))
// External file attributes
header.append(Data([0x00, 0x00, 0x00, 0x00]))
// Relative offset of local header
withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) }
// File name
header.append(filenameData)
return header
}
private func createEndOfCentralDirectoryRecord(
fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32
) -> Data {
var record = Data()
// End of central dir signature
record.append(Data([0x50, 0x4b, 0x05, 0x06]))
// Number of this disk
record.append(Data([0x00, 0x00]))
// Number of the disk with the start of the central directory
record.append(Data([0x00, 0x00]))
// Total number of entries in the central directory on this disk
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Total number of entries in the central directory
withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) }
// Size of the central directory
withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) }
// Offset of start of central directory
withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) }
// ZIP file comment length
record.append(Data([0x00, 0x00]))
return record
}
func testConnection() async throws {
guard isConfigured else {
throw SyncError.notConfigured
}
isTesting = true
defer { isTesting = false }
guard let url = URL(string: "\(serverURL)/health") else {
throw SyncError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 10
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SyncError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw SyncError.serverError(httpResponse.statusCode)
}
// Connection successful, mark as connected
isConnected = true
userDefaults.set(true, forKey: Keys.isConnected)
}
func triggerAutoSync(dataManager: ClimbingDataManager) {
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
print("Auto-sync failed: \(error)")
// Don't show UI errors for auto-sync failures
}
}
}
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
// These methods are no longer used but kept for reference
@available(*, deprecated, message: "Use simple timestamp-based sync instead")
private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws
-> ClimbDataBackup
{
print("Merging data - preserving all entities to prevent data loss")
// Merge gyms by ID, keeping most recently updated
let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms)
// Merge problems by ID, keeping most recently updated
let mergedProblems = mergeProblems(local: local.problems, server: server.problems)
// Merge sessions by ID, keeping most recently updated
let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions)
// Merge attempts by ID, keeping most recently updated
let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts)
print(
"Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)"
)
return ClimbDataBackup(
exportedAt: ISO8601DateFormatter().string(from: Date()),
version: "2.0",
formatVersion: "2.0",
gyms: mergedGyms,
problems: mergedProblems,
sessions: mergedSessions,
attempts: mergedAttempts
)
}
private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] {
var merged: [String: BackupGym] = [:]
// Add all local gyms
for gym in local {
merged[gym.id] = gym
}
// Add server gyms, replacing if newer
for serverGym in server {
if let localGym = merged[serverGym.id] {
// Keep the most recently updated
if isNewerThan(serverGym.updatedAt, localGym.updatedAt) {
merged[serverGym.id] = serverGym
}
} else {
// New gym from server
merged[serverGym.id] = serverGym
}
}
return Array(merged.values)
}
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
var merged: [String: BackupProblem] = [:]
// Add all local problems
for problem in local {
merged[problem.id] = problem
}
// Add server problems, replacing if newer or merging image paths
for serverProblem in server {
if let localProblem = merged[serverProblem.id] {
// Merge image paths from both sources
let localImages = Set(localProblem.imagePaths ?? [])
let serverImages = Set(serverProblem.imagePaths ?? [])
let mergedImages = Array(localImages.union(serverImages))
// Use most recently updated problem data but with merged images
let newerProblem =
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
? serverProblem : localProblem
merged[serverProblem.id] = BackupProblem(
id: newerProblem.id,
gymId: newerProblem.gymId,
name: newerProblem.name,
description: newerProblem.description,
climbType: newerProblem.climbType,
difficulty: newerProblem.difficulty,
tags: newerProblem.tags,
location: newerProblem.location,
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
isActive: newerProblem.isActive,
dateSet: newerProblem.dateSet,
notes: newerProblem.notes,
createdAt: newerProblem.createdAt,
updatedAt: newerProblem.updatedAt
)
} else {
// New problem from server
merged[serverProblem.id] = serverProblem
}
}
return Array(merged.values)
}
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
-> [BackupClimbSession]
{
var merged: [String: BackupClimbSession] = [:]
// Add all local sessions
for session in local {
merged[session.id] = session
}
// Add server sessions, replacing if newer
for serverSession in server {
if let localSession = merged[serverSession.id] {
// Keep the most recently updated
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
merged[serverSession.id] = serverSession
}
} else {
// New session from server
merged[serverSession.id] = serverSession
}
}
return Array(merged.values)
}
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
var merged: [String: BackupAttempt] = [:]
// Add all local attempts
for attempt in local {
merged[attempt.id] = attempt
}
// Add server attempts, replacing if newer
for serverAttempt in server {
if let localAttempt = merged[serverAttempt.id] {
// Keep the most recently created (attempts don't typically get updated)
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
merged[serverAttempt.id] = serverAttempt
}
} else {
// New attempt from server
merged[serverAttempt.id] = serverAttempt
}
}
return Array(merged.values)
}
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
let formatter = ISO8601DateFormatter()
guard let date1 = formatter.date(from: dateString1),
let date2 = formatter.date(from: dateString2)
else {
return false
}
return date1 > date2
}
func disconnect() {
isConnected = false
lastSyncTime = nil
syncError = nil
userDefaults.set(false, forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.lastSyncTime)
}
func clearConfiguration() {
serverURL = ""
authToken = ""
lastSyncTime = nil
isConnected = false
isAutoSyncEnabled = true
userDefaults.removeObject(forKey: Keys.lastSyncTime)
userDefaults.removeObject(forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
}
}
// Removed SyncTrigger enum - now using simple auto sync on any data change
enum SyncError: LocalizedError {
case notConfigured
case notConnected
case invalidURL
case invalidResponse
case unauthorized
case badRequest
case serverError(Int)
case decodingError(Error)
case exportFailed
case importFailed(Error)
case imageNotFound
case imageUploadFailed
var errorDescription: String? {
switch self {
case .notConfigured:
return "Sync server not configured. Please set server URL and auth token."
case .notConnected:
return "Not connected to sync server. Please test connection first."
case .invalidURL:
return "Invalid server URL."
case .invalidResponse:
return "Invalid response from server."
case .unauthorized:
return "Authentication failed. Check your auth token."
case .badRequest:
return "Bad request. Check your data format."
case .serverError(let code):
return "Server error (code \(code))."
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .exportFailed:
return "Failed to export local data."
case .importFailed(let error):
return "Failed to import data: \(error.localizedDescription)"
case .imageNotFound:
return "Image not found on server."
case .imageUploadFailed:
return "Failed to upload image to server."
}
}
}

View File

@@ -0,0 +1,85 @@
//
// DataStateManager.swift
import Foundation
/// Manages the overall data state timestamp for sync purposes. This tracks when any data in the
/// local database was last modified, independent of individual entity timestamps.
class DataStateManager {
private let userDefaults = UserDefaults.standard
private enum Keys {
static let lastModified = "openclimb_data_last_modified"
static let initialized = "openclimb_data_state_initialized"
}
/// Shared instance for app-wide use
static let shared = DataStateManager()
private init() {
// Initialize with current timestamp if this is the first time
if !isInitialized() {
print("DataStateManager: First time initialization")
// Set initial timestamp to a very old date so server data will be considered newer
let epochTime = "1970-01-01T00:00:00.000Z"
userDefaults.set(epochTime, forKey: Keys.lastModified)
markAsInitialized()
print("DataStateManager initialized with epoch timestamp: \(epochTime)")
} else {
print("DataStateManager: Already initialized, current timestamp: \(getLastModified())")
}
}
/// Updates the data state timestamp to the current time. Call this whenever any data is modified
/// (create, update, delete).
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("📝 iOS Data state updated to: \(now)")
}
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp
}
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z"
print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}
/// Sets the data state timestamp to a specific value. Used when importing data from server to
/// sync the state.
func setLastModified(_ timestamp: String) {
userDefaults.set(timestamp, forKey: Keys.lastModified)
print("Data state set to: \(timestamp)")
}
/// Resets the data state (for testing or complete data wipe).
func reset() {
userDefaults.removeObject(forKey: Keys.lastModified)
userDefaults.removeObject(forKey: Keys.initialized)
print("Data state reset")
}
/// Checks if the data state has been initialized.
private func isInitialized() -> Bool {
return userDefaults.bool(forKey: Keys.initialized)
}
/// Marks the data state as initialized.
private func markAsInitialized() {
userDefaults.set(true, forKey: Keys.initialized)
}
/// Gets debug information about the current state.
func getDebugInfo() -> String {
return "DataState(lastModified=\(getLastModified()), initialized=\(isInitialized()))"
}
}

View File

@@ -0,0 +1,176 @@
//
// ImageNamingUtils.swift
import CryptoKit
import Foundation
/// Utility for creating consistent image filenames across iOS and Android platforms.
/// Uses deterministic naming based on problem ID and timestamp to ensure sync compatibility.
class ImageNamingUtils {
private static let imageExtension = ".jpg"
private static let hashLength = 12 // First 12 chars of SHA-256
/// Generates a deterministic filename for a problem image.
/// Format: "problem_{hash}_{index}.jpg"
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - timestamp: ISO8601 timestamp when the image was created
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename that will be the same across platforms
static func generateImageFilename(problemId: String, timestamp: String, imageIndex: Int)
-> String
{
// Create a deterministic hash from problemId + timestamp + index
let input = "\(problemId)_\(timestamp)_\(imageIndex)"
let hash = createHash(from: input)
return "problem_\(hash)_\(imageIndex)\(imageExtension)"
}
/// Generates a deterministic filename for a problem image using current timestamp.
///
/// - Parameters:
/// - problemId: The ID of the problem this image belongs to
/// - imageIndex: The index of this image for the problem (0, 1, 2, etc.)
/// - Returns: A consistent filename
static func generateImageFilename(problemId: String, imageIndex: Int) -> String {
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Extracts problem ID from an image filename created by this utility.
/// Returns nil if the filename doesn't match our naming convention.
///
/// - Parameter filename: The image filename
/// - Returns: The hash identifier or nil if not a valid filename
static func extractProblemIdFromFilename(_ filename: String) -> String? {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return nil
}
// Format: problem_{hash}_{index}.jpg
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
guard parts.count == 3 && parts[0] == "problem" else {
return nil
}
// Return the hash as identifier
return parts[1]
}
/// Validates if a filename follows our naming convention.
///
/// - Parameter filename: The filename to validate
/// - Returns: true if it matches our convention, false otherwise
static func isValidImageFilename(_ filename: String) -> Bool {
guard filename.hasPrefix("problem_") && filename.hasSuffix(imageExtension) else {
return false
}
let nameWithoutExtension = String(filename.dropLast(imageExtension.count))
let parts = nameWithoutExtension.components(separatedBy: "_")
return parts.count == 3 && parts[0] == "problem" && parts[1].count == hashLength
&& Int(parts[2]) != nil
}
/// Migrates an existing UUID-based filename to our naming convention.
/// This is used during sync to rename downloaded images.
///
/// - Parameters:
/// - oldFilename: The existing filename (UUID-based)
/// - problemId: The problem ID this image belongs to
/// - imageIndex: The index of this image
/// - Returns: The new filename following our convention
static func migrateFilename(oldFilename: String, problemId: String, imageIndex: Int) -> String {
// If it's already using our convention, keep it
if isValidImageFilename(oldFilename) {
return oldFilename
}
// Generate new deterministic name
// Use current timestamp to maintain some consistency
let timestamp = ISO8601DateFormatter().string(from: Date())
return generateImageFilename(
problemId: problemId, timestamp: timestamp, imageIndex: imageIndex)
}
/// Creates a deterministic hash from input string.
/// Uses SHA-256 and takes first 12 characters for filename safety.
///
/// - Parameter input: The input string to hash
/// - Returns: First 12 characters of SHA-256 hash in lowercase
private static func createHash(from input: String) -> String {
let inputData = Data(input.utf8)
let hashed = SHA256.hash(data: inputData)
let hashString = hashed.compactMap { String(format: "%02x", $0) }.joined()
return String(hashString.prefix(hashLength))
}
/// Batch renames images for a problem to use our naming convention.
/// Returns a mapping of old filename -> new filename.
///
/// - Parameters:
/// - problemId: The problem ID
/// - existingFilenames: List of current image filenames for this problem
/// - Returns: Dictionary mapping old filename to new filename
static func batchRenameForProblem(problemId: String, existingFilenames: [String]) -> [String:
String]
{
var renameMap: [String: String] = [:]
for (index, oldFilename) in existingFilenames.enumerated() {
let newFilename = migrateFilename(
oldFilename: oldFilename, problemId: problemId, imageIndex: index)
if newFilename != oldFilename {
renameMap[oldFilename] = newFilename
}
}
return renameMap
}
/// Validates that a collection of filenames follow our naming convention.
///
/// - Parameter filenames: Array of filenames to validate
/// - Returns: Dictionary with validation results
static func validateFilenames(_ filenames: [String]) -> ImageValidationResult {
var validImages: [String] = []
var invalidImages: [String] = []
for filename in filenames {
if isValidImageFilename(filename) {
validImages.append(filename)
} else {
invalidImages.append(filename)
}
}
return ImageValidationResult(
totalImages: filenames.count,
validImages: validImages,
invalidImages: invalidImages
)
}
}
/// Result of image filename validation
struct ImageValidationResult {
let totalImages: Int
let validImages: [String]
let invalidImages: [String]
var isAllValid: Bool {
return invalidImages.isEmpty
}
var validPercentage: Double {
guard totalImages > 0 else { return 100.0 }
return (Double(validImages.count) / Double(totalImages)) * 100.0
}
}

View File

@@ -1,4 +1,3 @@
import Compression
import Foundation
import zlib
@@ -10,7 +9,7 @@ struct ZipUtils {
private static let METADATA_FILENAME = "metadata.txt"
static func createExportZip(
exportData: ClimbDataExport,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) throws -> Data {
@@ -196,7 +195,7 @@ struct ZipUtils {
}
private static func createMetadata(
exportData: ClimbDataExport,
exportData: ClimbDataBackup,
referencedImagePaths: Set<String>
) -> String {
return """

View File

@@ -29,6 +29,9 @@ class ClimbingDataManager: ObservableObject {
private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol?
// Sync service for automatic syncing
let syncService = SyncService()
private enum Keys {
static let gyms = "openclimb_gyms"
static let problems = "openclimb_problems"
@@ -200,6 +203,7 @@ class ClimbingDataManager: ObservableObject {
func addGym(_ gym: Gym) {
gyms.append(gym)
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
}
@@ -208,6 +212,7 @@ class ClimbingDataManager: ObservableObject {
if let index = gyms.firstIndex(where: { $0.id == gym.id }) {
gyms[index] = gym
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
}
@@ -229,6 +234,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the gym
gyms.removeAll { $0.id == gym.id }
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
}
@@ -240,14 +246,19 @@ class ClimbingDataManager: ObservableObject {
func addProblem(_ problem: Problem) {
problems.append(problem)
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
}
func updateProblem(_ problem: Problem) {
if let index = problems.firstIndex(where: { $0.id == problem.id }) {
problems[index] = problem
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
}
@@ -264,6 +275,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the problem
problems.removeAll { $0.id == problem.id }
saveProblems()
DataStateManager.shared.updateDataState()
}
func problem(withId id: UUID) -> Problem? {
@@ -290,6 +302,7 @@ class ClimbingDataManager: ObservableObject {
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session started successfully"
clearMessageAfterDelay()
@@ -317,9 +330,13 @@ class ClimbingDataManager: ObservableObject {
saveActiveSession()
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session completed successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
// MARK: - End Live Activity after session ends
Task {
await LiveActivityManager.shared.endLiveActivity()
@@ -337,6 +354,7 @@ class ClimbingDataManager: ObservableObject {
}
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session updated successfully"
clearMessageAfterDelay()
@@ -359,6 +377,7 @@ class ClimbingDataManager: ObservableObject {
// Delete the session
sessions.removeAll { $0.id == session.id }
saveSessions()
DataStateManager.shared.updateDataState()
successMessage = "Session deleted successfully"
clearMessageAfterDelay()
}
@@ -380,8 +399,12 @@ class ClimbingDataManager: ObservableObject {
func addAttempt(_ attempt: Attempt) {
attempts.append(attempt)
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt logged successfully"
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
clearMessageAfterDelay()
// Update Live Activity when new attempt is added
@@ -392,6 +415,7 @@ class ClimbingDataManager: ObservableObject {
if let index = attempts.firstIndex(where: { $0.id == attempt.id }) {
attempts[index] = attempt
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt updated successfully"
clearMessageAfterDelay()
@@ -403,6 +427,7 @@ class ClimbingDataManager: ObservableObject {
func deleteAttempt(_ attempt: Attempt) {
attempts.removeAll { $0.id == attempt.id }
saveAttempts()
DataStateManager.shared.updateDataState()
successMessage = "Attempt deleted successfully"
clearMessageAfterDelay()
@@ -464,6 +489,7 @@ class ClimbingDataManager: ObservableObject {
userDefaults.removeObject(forKey: Keys.attempts)
userDefaults.removeObject(forKey: Keys.activeSession)
DataStateManager.shared.reset()
successMessage = "All data has been reset"
clearMessageAfterDelay()
}
@@ -473,13 +499,14 @@ class ClimbingDataManager: ObservableObject {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let exportData = ClimbDataExport(
let exportData = ClimbDataBackup(
exportedAt: dateFormatter.string(from: Date()),
version: "2.0",
gyms: gyms.map { AndroidGym(from: $0) },
problems: problems.map { AndroidProblem(from: $0) },
sessions: sessions.map { AndroidClimbSession(from: $0) },
attempts: attempts.map { AndroidAttempt(from: $0) }
formatVersion: "2.0",
gyms: gyms.map { BackupGym(from: $0) },
problems: problems.map { BackupProblem(from: $0) },
sessions: sessions.map { BackupClimbSession(from: $0) },
attempts: attempts.map { BackupAttempt(from: $0) }
)
// Collect referenced image paths
@@ -529,7 +556,7 @@ class ClimbingDataManager: ObservableObject {
print("Raw JSON content preview:")
print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...")
let importData = try decoder.decode(ClimbDataExport.self, from: importResult.jsonData)
let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData)
print("Successfully decoded import data:")
print("- Gyms: \(importData.gyms.count)")
@@ -546,16 +573,19 @@ class ClimbingDataManager: ObservableObject {
imagePathMapping: importResult.imagePathMapping
)
self.gyms = importData.gyms.map { $0.toGym() }
self.problems = updatedProblems.map { $0.toProblem() }
self.sessions = importData.sessions.map { $0.toClimbSession() }
self.attempts = importData.attempts.map { $0.toAttempt() }
self.gyms = try importData.gyms.map { try $0.toGym() }
self.problems = try updatedProblems.map { try $0.toProblem() }
self.sessions = try importData.sessions.map { try $0.toClimbSession() }
self.attempts = try importData.attempts.map { try $0.toAttempt() }
saveGyms()
saveProblems()
saveSessions()
saveAttempts()
// Update data state to current time since we just imported new data
DataStateManager.shared.updateDataState()
successMessage =
"Data imported successfully with \(importResult.imagePathMapping.count) images"
clearMessageAfterDelay()
@@ -584,337 +614,6 @@ class ClimbingDataManager: ObservableObject {
}
}
struct ClimbDataExport: Codable {
let exportedAt: String
let version: String
let gyms: [AndroidGym]
let problems: [AndroidProblem]
let sessions: [AndroidClimbSession]
let attempts: [AndroidAttempt]
init(
exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem],
sessions: [AndroidClimbSession], attempts: [AndroidAttempt]
) {
self.exportedAt = exportedAt
self.version = version
self.gyms = gyms
self.problems = problems
self.sessions = sessions
self.attempts = attempts
}
}
struct AndroidGym: Codable {
let id: String
let name: String
let location: String?
let supportedClimbTypes: [ClimbType]
let difficultySystems: [DifficultySystem]
let customDifficultyGrades: [String]
let notes: String?
let createdAt: String
let updatedAt: String
init(from gym: Gym) {
self.id = gym.id.uuidString
self.name = gym.name
self.location = gym.location
self.supportedClimbTypes = gym.supportedClimbTypes
self.difficultySystems = gym.difficultySystems
self.customDifficultyGrades = gym.customDifficultyGrades
self.notes = gym.notes
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.createdAt = formatter.string(from: gym.createdAt)
self.updatedAt = formatter.string(from: gym.updatedAt)
}
init(
id: String, name: String, location: String?, supportedClimbTypes: [ClimbType],
difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [],
notes: String?, createdAt: String, updatedAt: String
) {
self.id = id
self.name = name
self.location = location
self.supportedClimbTypes = supportedClimbTypes
self.difficultySystems = difficultySystems
self.customDifficultyGrades = customDifficultyGrades
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toGym() -> Gym {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let gymId = UUID(uuidString: id) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Gym.fromImport(
id: gymId,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidProblem: Codable {
let id: String
let gymId: String
let name: String?
let description: String?
let climbType: ClimbType
let difficulty: DifficultyGrade
let tags: [String]
let location: String?
let imagePaths: [String]?
let isActive: Bool
let dateSet: String?
let notes: String?
let createdAt: String
let updatedAt: String
init(from problem: Problem) {
self.id = problem.id.uuidString
self.gymId = problem.gymId.uuidString
self.name = problem.name
self.description = problem.description
self.climbType = problem.climbType
self.difficulty = problem.difficulty
self.tags = problem.tags
self.location = problem.location
self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths
self.isActive = problem.isActive
self.notes = problem.notes
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil
self.createdAt = formatter.string(from: problem.createdAt)
self.updatedAt = formatter.string(from: problem.updatedAt)
}
init(
id: String, gymId: String, name: String?, description: String?, climbType: ClimbType,
difficulty: DifficultyGrade, tags: [String] = [],
location: String? = nil,
imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil,
notes: String? = nil,
createdAt: String, updatedAt: String
) {
self.id = id
self.gymId = gymId
self.name = name
self.description = description
self.climbType = climbType
self.difficulty = difficulty
self.tags = tags
self.location = location
self.imagePaths = imagePaths
self.isActive = isActive
self.dateSet = dateSet
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toProblem() -> Problem {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let problemId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return Problem.fromImport(
id: problemId,
gymId: preservedGymId,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
tags: tags,
location: location,
imagePaths: imagePaths ?? [],
isActive: isActive,
dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem {
return AndroidProblem(
id: self.id,
gymId: self.gymId,
name: self.name,
description: self.description,
climbType: self.climbType,
difficulty: self.difficulty,
tags: self.tags,
location: self.location,
imagePaths: newImagePaths.isEmpty ? nil : newImagePaths,
isActive: self.isActive,
dateSet: self.dateSet,
notes: self.notes,
createdAt: self.createdAt,
updatedAt: self.updatedAt
)
}
}
struct AndroidClimbSession: Codable {
let id: String
let gymId: String
let date: String
let startTime: String?
let endTime: String?
let duration: Int64?
let status: SessionStatus
let notes: String?
let createdAt: String
let updatedAt: String
init(from session: ClimbSession) {
self.id = session.id.uuidString
self.gymId = session.gymId.uuidString
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.date = formatter.string(from: session.date)
self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil
self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil
self.duration = session.duration != nil ? Int64(session.duration!) : nil
self.status = session.status
self.notes = session.notes
self.createdAt = formatter.string(from: session.createdAt)
self.updatedAt = formatter.string(from: session.updatedAt)
}
init(
id: String, gymId: String, date: String, startTime: String?, endTime: String?,
duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String,
updatedAt: String
) {
self.id = id
self.gymId = gymId
self.date = date
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.notes = notes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toClimbSession() -> ClimbSession {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
// Preserve original IDs and dates
let sessionId = UUID(uuidString: id) ?? UUID()
let preservedGymId = UUID(uuidString: gymId) ?? UUID()
let sessionDate = formatter.date(from: date) ?? Date()
let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil
let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil
let createdDate = formatter.date(from: createdAt) ?? Date()
let updatedDate = formatter.date(from: updatedAt) ?? Date()
return ClimbSession.fromImport(
id: sessionId,
gymId: preservedGymId,
date: sessionDate,
startTime: sessionStartTime,
endTime: sessionEndTime,
duration: duration != nil ? Int(duration!) : nil,
status: status,
notes: notes,
createdAt: createdDate,
updatedAt: updatedDate
)
}
}
struct AndroidAttempt: Codable {
let id: String
let sessionId: String
let problemId: String
let result: AttemptResult
let highestHold: String?
let notes: String?
let duration: Int64?
let restTime: Int64?
let timestamp: String
let createdAt: String
init(from attempt: Attempt) {
self.id = attempt.id.uuidString
self.sessionId = attempt.sessionId.uuidString
self.problemId = attempt.problemId.uuidString
self.result = attempt.result
self.highestHold = attempt.highestHold
self.notes = attempt.notes
self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil
self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
self.timestamp = formatter.string(from: attempt.timestamp)
self.createdAt = formatter.string(from: attempt.createdAt)
}
init(
id: String, sessionId: String, problemId: String, result: AttemptResult,
highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?,
timestamp: String, createdAt: String
) {
self.id = id
self.sessionId = sessionId
self.problemId = problemId
self.result = result
self.highestHold = highestHold
self.notes = notes
self.duration = duration
self.restTime = restTime
self.timestamp = timestamp
self.createdAt = createdAt
}
func toAttempt() -> Attempt {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
let attemptId = UUID(uuidString: id) ?? UUID()
let preservedSessionId = UUID(uuidString: sessionId) ?? UUID()
let preservedProblemId = UUID(uuidString: problemId) ?? UUID()
let attemptTimestamp = formatter.date(from: timestamp) ?? Date()
let createdDate = formatter.date(from: createdAt) ?? Date()
return Attempt.fromImport(
id: attemptId,
sessionId: preservedSessionId,
problemId: preservedProblemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration != nil ? Int(duration!) : nil,
restTime: restTime != nil ? Int(restTime!) : nil,
timestamp: attemptTimestamp,
createdAt: createdDate
)
}
}
extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>()
@@ -949,9 +648,9 @@ extension ClimbingDataManager {
}
private func updateProblemImagePaths(
problems: [AndroidProblem],
problems: [BackupProblem],
imagePathMapping: [String: String]
) -> [AndroidProblem] {
) -> [BackupProblem] {
return problems.map { problem in
let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in
let fileName = URL(fileURLWithPath: oldPath).lastPathComponent
@@ -1298,7 +997,7 @@ extension ClimbingDataManager {
saveAttempts()
}
private func validateImportData(_ importData: ClimbDataExport) throws {
private func validateImportData(_ importData: ClimbDataBackup) throws {
if importData.gyms.isEmpty {
throw NSError(
domain: "ImportError", code: 1,

View File

@@ -130,14 +130,8 @@ final class LiveActivityManager {
completedProblems: completedProblems
)
do {
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("✅ Live Activity updated successfully")
} catch {
print("❌ Failed to update Live Activity: \(error)")
// If update fails, the activity might have been dismissed
self.currentActivity = nil
}
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("✅ Live Activity updated successfully")
}
/// Call this when a ClimbSession ends to end the Live Activity

View File

@@ -168,15 +168,9 @@ struct ActiveSessionBanner: View {
.onDisappear {
stopTimer()
}
.background(
NavigationLink(
destination: SessionDetailView(sessionId: session.id),
isActive: $navigateToDetail
) {
EmptyView()
}
.hidden()
)
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
private func formatDuration(from start: Date, to end: Date) -> String {

View File

@@ -12,6 +12,9 @@ struct SettingsView: View {
var body: some View {
List {
SyncSection()
.environmentObject(dataManager.syncService)
DataManagementSection(
activeSheet: $activeSheet
)
@@ -303,6 +306,361 @@ struct ExportDataView: View {
}
}
struct SyncSection: View {
@EnvironmentObject var syncService: SyncService
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var showingSyncSettings = false
@State private var showingDisconnectAlert = false
var body: some View {
Section("Sync") {
// Sync Status
HStack {
Image(
systemName: syncService.isConnected
? "checkmark.circle.fill"
: syncService.isConfigured
? "exclamationmark.triangle.fill"
: "exclamationmark.circle.fill"
)
.foregroundColor(
syncService.isConnected
? .green
: syncService.isConfigured
? .orange
: .red
)
VStack(alignment: .leading) {
Text("Sync Server")
.font(.headline)
Text(
syncService.isConnected
? "Connected"
: syncService.isConfigured
? "Configured - Not tested"
: "Not configured"
)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
// Configure Server
Button(action: {
showingSyncSettings = true
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.blue)
Text("Configure Server")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
if syncService.isConfigured {
// Sync Now - only show if connected
if syncService.isConnected {
Button(action: {
performSync()
}) {
HStack {
if syncService.isSyncing {
ProgressView()
.scaleEffect(0.8)
Text("Syncing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(.green)
Text("Sync Now")
Spacer()
if let lastSync = syncService.lastSyncTime {
Text(
RelativeDateTimeFormatter().localizedString(
for: lastSync, relativeTo: Date())
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.disabled(syncService.isSyncing)
.foregroundColor(.primary)
}
// Auto-sync configuration - always visible for testing
HStack {
VStack(alignment: .leading) {
Text("Auto-sync")
Text("Sync automatically on app launch and data changes")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Toggle(
"",
isOn: Binding(
get: { syncService.isAutoSyncEnabled },
set: { syncService.isAutoSyncEnabled = $0 }
)
)
.disabled(!syncService.isConnected)
}
.foregroundColor(.primary)
// Disconnect option - only show if connected
if syncService.isConnected {
Button(action: {
showingDisconnectAlert = true
}) {
HStack {
Image(systemName: "power")
.foregroundColor(.orange)
Text("Disconnect")
Spacer()
}
}
.foregroundColor(.primary)
}
if let error = syncService.syncError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding(.leading, 24)
}
}
}
.sheet(isPresented: $showingSyncSettings) {
SyncSettingsView()
.environmentObject(syncService)
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
}
private func performSync() {
Task {
do {
try await syncService.syncWithServer(dataManager: dataManager)
} catch {
print("Sync failed: \(error)")
}
}
}
}
struct SyncSettingsView: View {
@EnvironmentObject var syncService: SyncService
@Environment(\.dismiss) private var dismiss
@State private var serverURL: String = ""
@State private var authToken: String = ""
@State private var showingDisconnectAlert = false
@State private var isTesting = false
@State private var showingTestResult = false
@State private var testResultMessage = ""
var body: some View {
NavigationView {
Form {
Section {
TextField("Server URL", text: $serverURL)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: serverURL.isEmpty) {
Text("http://your-server:8080")
.foregroundColor(.secondary)
}
TextField("Auth Token", text: $authToken)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.placeholder(when: authToken.isEmpty) {
Text("your-secret-token")
.foregroundColor(.secondary)
}
} header: {
Text("Server Configuration")
} footer: {
Text(
"Enter your sync server URL and authentication token. You must test the connection before syncing is available."
)
}
Section {
Button(action: {
testConnection()
}) {
HStack {
if isTesting {
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.secondary)
} else {
Image(systemName: "network")
.foregroundColor(.blue)
Text("Test Connection")
Spacer()
if syncService.isConnected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
}
}
.disabled(
isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
.foregroundColor(.primary)
} header: {
Text("Connection")
} footer: {
Text("Test the connection to verify your server settings before saving.")
}
Section {
Button("Disconnect from Server") {
showingDisconnectAlert = true
}
.foregroundColor(.orange)
Button("Clear Configuration") {
syncService.clearConfiguration()
serverURL = ""
authToken = ""
}
.foregroundColor(.red)
} footer: {
Text(
"Disconnect will sign you out but keep settings. Clear Configuration removes all sync settings."
)
}
}
.navigationTitle("Sync Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
let newURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed
if newURL != syncService.serverURL || newToken != syncService.authToken {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
}
syncService.serverURL = newURL
syncService.authToken = newToken
dismiss()
}
.fontWeight(.semibold)
}
}
}
.onAppear {
serverURL = syncService.serverURL
authToken = syncService.authToken
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
Button("Disconnect", role: .destructive) {
syncService.disconnect()
dismiss()
}
} message: {
Text(
"This will sign you out but keep your server settings. You'll need to test the connection again to sync."
)
}
.alert("Connection Test", isPresented: $showingTestResult) {
Button("OK") {}
} message: {
Text(testResultMessage)
}
}
private func testConnection() {
isTesting = true
let testURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let testToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Store original values in case test fails
let originalURL = syncService.serverURL
let originalToken = syncService.authToken
Task {
do {
// Temporarily set the values for testing
syncService.serverURL = testURL
syncService.authToken = testToken
try await syncService.testConnection()
await MainActor.run {
isTesting = false
testResultMessage =
"Connection successful! You can now save and sync your data."
showingTestResult = true
}
} catch {
// Restore original values if test failed
syncService.serverURL = originalURL
syncService.authToken = originalToken
await MainActor.run {
isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)"
showingTestResult = true
}
}
}
}
}
// Removed AutoSyncSettingsView - now using simple toggle in main settings
extension View {
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content
) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}
struct ImportDataView: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@Environment(\.dismiss) private var dismiss

303
sync-server/DEPLOY.md Normal file
View File

@@ -0,0 +1,303 @@
# OpenClimb Sync Server Deployment Guide
This guide covers deploying the OpenClimb Sync Server using the automated Docker build and deployment system.
## Overview
The sync server is automatically built into a Docker container via GitHub Actions and can be deployed to any Docker-compatible environment.
## Prerequisites
- Docker and Docker Compose installed
- Access to the container registry (configured in GitHub secrets)
- Basic understanding of Docker deployments
## Quick Start
### 1. Automated Deployment (Recommended)
```bash
# Clone the repository
git clone <your-repo-url>
cd OpenClimb/sync-server
# Run the deployment script
./deploy.sh
```
The script will:
- Create necessary directories
- Pull the latest container image
- Stop any existing containers
- Start the new container
- Verify deployment success
### 2. Manual Deployment
```bash
# Pull the latest image
docker pull your-registry.com/username/openclimb-sync-server:latest
# Create environment file
cp .env.example .env.prod
# Edit .env.prod with your configuration
# Deploy with docker-compose
docker-compose -f docker-compose.prod.yml up -d
```
## Configuration
### Environment Variables
Create a `.env.prod` file with the following variables:
```bash
# Container registry settings
REPO_HOST=your-registry.example.com
REPO_OWNER=your-username
# Server configuration
AUTH_TOKEN=your-secure-auth-token-here-make-it-long-and-random
PORT=8080
# Optional: Custom domain (for Traefik)
TRAEFIK_HOST=sync.openclimb.example.com
```
### Required Secrets (GitHub)
Configure these secrets in your GitHub repository settings:
- `REPO_HOST`: Your container registry hostname
- `DEPLOY_TOKEN`: Authentication token for the registry
## Container Build Process
The GitHub Action (`sync-server-deploy.yml`) automatically:
1. **Triggers on:**
- Push to `main` branch (when sync-server files change)
- Pull requests to `main` branch
2. **Build Process:**
- Uses multi-stage Docker build
- Compiles Go binary in builder stage
- Creates minimal Alpine-based runtime image
- Pushes to container registry with tags:
- `latest` (always points to newest)
- `<commit-sha>` (specific version)
3. **Caching:**
- Uses GitHub Actions cache for faster builds
- Incremental builds when possible
## Deployment Options
### Option 1: Simple Docker Run
```bash
docker run -d \
--name openclimb-sync-server \
-p 8080:8080 \
-v $(pwd)/data:/root/data \
-e AUTH_TOKEN=your-token-here \
your-registry.com/username/openclimb-sync-server:latest
```
### Option 2: Docker Compose (Recommended)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Option 3: Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: openclimb-sync-server
spec:
replicas: 1
selector:
matchLabels:
app: openclimb-sync-server
template:
metadata:
labels:
app: openclimb-sync-server
spec:
containers:
- name: sync-server
image: your-registry.com/username/openclimb-sync-server:latest
ports:
- containerPort: 8080
env:
- name: AUTH_TOKEN
valueFrom:
secretKeyRef:
name: openclimb-secrets
key: auth-token
volumeMounts:
- name: data-volume
mountPath: /root/data
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: openclimb-data
```
## Data Persistence
The sync server stores data in `/root/data` inside the container. **Always mount a volume** to preserve data:
```bash
# Local directory mounting
-v $(pwd)/data:/root/data
# Named volume (recommended for production)
-v openclimb-data:/root/data
```
### Data Structure
```
data/
├── climb_data.json # Main sync data
├── images/ # Uploaded images
│ ├── problem_*.jpg
│ └── ...
└── logs/ # Server logs (optional)
```
## Monitoring and Maintenance
### Health Check
```bash
curl http://localhost:8080/health
```
### View Logs
```bash
# Docker Compose
docker-compose -f docker-compose.prod.yml logs -f
# Direct Docker
docker logs -f openclimb-sync-server
```
### Update to Latest Version
```bash
# Using deploy script
./deploy.sh
# Manual update
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
```
## Reverse Proxy Setup (Optional)
### Nginx
```nginx
server {
listen 80;
server_name sync.openclimb.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Traefik (Labels included in docker-compose.prod.yml)
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.openclimb-sync.rule=Host(`sync.openclimb.example.com`)"
- "traefik.http.routers.openclimb-sync.tls.certresolver=letsencrypt"
```
## Security Considerations
1. **AUTH_TOKEN**: Use a long, random token (32+ characters)
2. **HTTPS**: Always use HTTPS in production (via reverse proxy)
3. **Firewall**: Only expose port 8080 to your reverse proxy, not publicly
4. **Updates**: Regularly update to the latest container image
5. **Backups**: Regularly backup the `data/` directory
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker logs openclimb-sync-server
# Common issues:
# - Missing AUTH_TOKEN environment variable
# - Port 8080 already in use
# - Insufficient permissions on data directory
```
### Sync Fails from Mobile Apps
```bash
# Verify server is accessible
curl -H "Authorization: Bearer your-token" http://your-server:8080/sync
# Check server logs for authentication errors
docker logs openclimb-sync-server | grep "401\|403"
```
### Image Upload Issues
```bash
# Check disk space
df -h
# Verify data directory permissions
ls -la data/
```
## Performance Tuning
For high-load deployments:
```yaml
# docker-compose.prod.yml
services:
openclimb-sync-server:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
```
## Backup Strategy
```bash
#!/bin/bash
# backup.sh - Run daily via cron
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/openclimb"
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Backup data directory
tar -czf "$BACKUP_DIR/openclimb_data_$DATE.tar.gz" \
-C /path/to/sync-server data/
# Keep only last 30 days
find "$BACKUP_DIR" -name "openclimb_data_*.tar.gz" -mtime +30 -delete
```
## Support
- **Issues**: Create an issue in the GitHub repository
- **Documentation**: Check the main OpenClimb README
- **Logs**: Always

14
sync/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# OpenClimb Sync Server Configuration
# Required: Secret token for authentication
# Generate a secure random token and share it between your apps and server
AUTH_TOKEN=your-secure-secret-token-here
# Optional: Port to run the server on (default: 8080)
PORT=8080
# Optional: Path to store the sync data (default: ./data/climb_data.json)
DATA_FILE=./data/climb_data.json
# Optional: Directory to store images (default: ./data/images)
IMAGES_DIR=./data/images

16
sync/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Binaries
sync-server
openclimb-sync
# Go workspace file
go.work
# Data directory
data/
# Environment files
.env
.env.local
# OS generated files
.DS_Store

14
sync/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o sync-server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/sync-server .
EXPOSE 8080
CMD ["./sync-server"]

12
sync/docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
openclimb-sync:
image: ${IMAGE}
ports:
- "8080:8080"
environment:
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
- DATA_FILE=/data/climb_data.json
- IMAGES_DIR=/data/images
volumes:
- ./data:/data
restart: unless-stopped

3
sync/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module openclimb-sync
go 1.25

358
sync/main.go Normal file
View File

@@ -0,0 +1,358 @@
package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func min(a, b int) int {
if a < b {
return a
}
return b
}
type ClimbDataBackup struct {
ExportedAt string `json:"exportedAt"`
Version string `json:"version"`
FormatVersion string `json:"formatVersion"`
Gyms []BackupGym `json:"gyms"`
Problems []BackupProblem `json:"problems"`
Sessions []BackupClimbSession `json:"sessions"`
Attempts []BackupAttempt `json:"attempts"`
}
type BackupGym struct {
ID string `json:"id"`
Name string `json:"name"`
Location *string `json:"location,omitempty"`
SupportedClimbTypes []string `json:"supportedClimbTypes"`
DifficultySystems []string `json:"difficultySystems"`
CustomDifficultyGrades []string `json:"customDifficultyGrades"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BackupProblem struct {
ID string `json:"id"`
GymID string `json:"gymId"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
ClimbType string `json:"climbType"`
Difficulty DifficultyGrade `json:"difficulty"`
Tags []string `json:"tags"`
Location *string `json:"location,omitempty"`
ImagePaths []string `json:"imagePaths,omitempty"`
IsActive bool `json:"isActive"`
DateSet *string `json:"dateSet,omitempty"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type DifficultyGrade struct {
System string `json:"system"`
Grade string `json:"grade"`
NumericValue int `json:"numericValue"`
}
type BackupClimbSession struct {
ID string `json:"id"`
GymID string `json:"gymId"`
Date string `json:"date"`
StartTime *string `json:"startTime,omitempty"`
EndTime *string `json:"endTime,omitempty"`
Duration *int64 `json:"duration,omitempty"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BackupAttempt struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
ProblemID string `json:"problemId"`
Result string `json:"result"`
HighestHold *string `json:"highestHold,omitempty"`
Notes *string `json:"notes,omitempty"`
Duration *int64 `json:"duration,omitempty"`
RestTime *int64 `json:"restTime,omitempty"`
Timestamp string `json:"timestamp"`
CreatedAt string `json:"createdAt"`
}
type SyncServer struct {
authToken string
dataFile string
imagesDir string
}
func (s *SyncServer) authenticate(r *http.Request) bool {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
return false
}
token := strings.TrimPrefix(authHeader, "Bearer ")
return subtle.ConstantTimeCompare([]byte(token), []byte(s.authToken)) == 1
}
func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
log.Printf("Loading data from: %s", s.dataFile)
if _, err := os.Stat(s.dataFile); os.IsNotExist(err) {
log.Printf("Data file does not exist, creating empty backup")
return &ClimbDataBackup{
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}, nil
}
data, err := os.ReadFile(s.dataFile)
if err != nil {
log.Printf("Failed to read data file: %v", err)
return nil, err
}
log.Printf("Read %d bytes from data file", len(data))
log.Printf("File content preview: %s", string(data[:min(200, len(data))]))
var backup ClimbDataBackup
if err := json.Unmarshal(data, &backup); err != nil {
log.Printf("Failed to unmarshal JSON: %v", err)
return nil, err
}
log.Printf("Loaded backup: gyms=%d, problems=%d, sessions=%d, attempts=%d",
len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts))
return &backup, nil
}
func (s *SyncServer) saveData(backup *ClimbDataBackup) error {
backup.ExportedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.MarshalIndent(backup, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(s.dataFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Ensure images directory exists
if err := os.MkdirAll(s.imagesDir, 0755); err != nil {
return err
}
return os.WriteFile(s.dataFile, data, 0644)
}
func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized access attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
log.Printf("GET /sync request from %s", r.RemoteAddr)
backup, err := s.loadData()
if err != nil {
log.Printf("Failed to load data: %v", err)
http.Error(w, "Failed to load data", http.StatusInternalServerError)
return
}
log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d",
r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backup)
}
func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized sync attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var backup ClimbDataBackup
if err := json.NewDecoder(r.Body).Decode(&backup); err != nil {
log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err)
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := s.saveData(&backup); err != nil {
log.Printf("Failed to save data: %v", err)
http.Error(w, "Failed to save data", http.StatusInternalServerError)
return
}
log.Printf("Data synced by %s", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backup)
}
func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *SyncServer) handleImageUpload(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized image upload attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Missing filename parameter", http.StatusBadRequest)
return
}
imageData, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read image data", http.StatusBadRequest)
return
}
imagePath := filepath.Join(s.imagesDir, filename)
if err := os.WriteFile(imagePath, imageData, 0644); err != nil {
log.Printf("Failed to save image %s: %v", filename, err)
http.Error(w, "Failed to save image", http.StatusInternalServerError)
return
}
log.Printf("Image uploaded: %s (%d bytes) by %s", filename, len(imageData), r.RemoteAddr)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "uploaded"})
}
func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request) {
if !s.authenticate(r) {
log.Printf("Unauthorized image download attempt from %s", r.RemoteAddr)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filename := r.URL.Query().Get("filename")
if filename == "" {
http.Error(w, "Missing filename parameter", http.StatusBadRequest)
return
}
imagePath := filepath.Join(s.imagesDir, filename)
imageData, err := os.ReadFile(imagePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Image not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to read image", http.StatusInternalServerError)
}
return
}
// Set appropriate content type based on file extension
ext := filepath.Ext(filename)
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.WriteHeader(http.StatusOK)
w.Write(imageData)
}
func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGet(w, r)
case http.MethodPut:
s.handlePut(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func main() {
authToken := os.Getenv("AUTH_TOKEN")
if authToken == "" {
log.Fatal("AUTH_TOKEN environment variable is required")
}
dataFile := os.Getenv("DATA_FILE")
if dataFile == "" {
dataFile = "./data/climb_data.json"
}
imagesDir := os.Getenv("IMAGES_DIR")
if imagesDir == "" {
imagesDir = "./data/images"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &SyncServer{
authToken: authToken,
dataFile: dataFile,
imagesDir: imagesDir,
}
http.HandleFunc("/sync", server.handleSync)
http.HandleFunc("/health", server.handleHealth)
http.HandleFunc("/images/upload", server.handleImageUpload)
http.HandleFunc("/images/download", server.handleImageDownload)
fmt.Printf("OpenClimb sync server starting on port %s\n", port)
fmt.Printf("Data file: %s\n", dataFile)
fmt.Printf("Images directory: %s\n", imagesDir)
fmt.Printf("Health check available at /health\n")
fmt.Printf("Image upload: POST /images/upload?filename=<name>\n")
fmt.Printf("Image download: GET /images/download?filename=<name>\n")
log.Fatal(http.ListenAndServe(":"+port, nil))
}

31
sync/run.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# OpenClimb Sync Server Runner
set -e
# Default values
AUTH_TOKEN=${AUTH_TOKEN:-}
PORT=${PORT:-8080}
DATA_FILE=${DATA_FILE:-./data/climb_data.json}
# Check if AUTH_TOKEN is set
if [ -z "$AUTH_TOKEN" ]; then
echo "Error: AUTH_TOKEN environment variable must be set"
echo "Usage: AUTH_TOKEN=your-secret-token ./run.sh"
echo "Or: export AUTH_TOKEN=your-secret-token && ./run.sh"
exit 1
fi
# Create data directory if it doesn't exist
mkdir -p "$(dirname "$DATA_FILE")"
# Build and run
echo "Building OpenClimb sync server..."
go build -o sync-server .
echo "Starting server on port $PORT"
echo "Data will be stored in: $DATA_FILE"
echo "Images will be stored in: ${IMAGES_DIR:-./data/images}"
echo "Use Authorization: Bearer $AUTH_TOKEN in your requests"
echo ""
exec ./sync-server

1
sync/version.md Normal file
View File

@@ -0,0 +1 @@
1.0.0