diff --git a/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive b/android/.kotlin/sessions/kotlin-compiler-12230421336915548227.salive new file mode 100644 index 0000000..e69de29 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1b83931..dc0c78f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 26 - versionName = "1.5.1" + versionCode = 27 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt new file mode 100644 index 0000000..e1658a7 --- /dev/null +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -0,0 +1,226 @@ +package com.atridad.openclimb.data.format + +import com.atridad.openclimb.data.model.* +import kotlinx.serialization.Serializable + +/** Root structure for OpenClimb backup data */ +@Serializable +data class ClimbDataBackup( + val exportedAt: String, + val version: String = "2.0", + val formatVersion: String = "2.0", + val gyms: List, + val problems: List, + val sessions: List, + val attempts: List +) + +/** Platform-neutral gym representation for backup/restore */ +@Serializable +data class BackupGym( + val id: String, + val name: String, + val location: String? = null, + val supportedClimbTypes: List, + val difficultySystems: List, + val customDifficultyGrades: List = emptyList(), + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupGym from native Android Gym model */ + fun fromGym(gym: Gym): BackupGym { + return BackupGym( + id = gym.id, + name = gym.name, + location = gym.location, + supportedClimbTypes = gym.supportedClimbTypes, + difficultySystems = gym.difficultySystems, + customDifficultyGrades = gym.customDifficultyGrades, + notes = gym.notes, + createdAt = gym.createdAt, + updatedAt = gym.updatedAt + ) + } + } + + /** Convert to native Android Gym model */ + fun toGym(): Gym { + return Gym( + id = id, + name = name, + location = location, + supportedClimbTypes = supportedClimbTypes, + difficultySystems = difficultySystems, + customDifficultyGrades = customDifficultyGrades, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral problem representation for backup/restore */ +@Serializable +data class BackupProblem( + val id: String, + val gymId: String, + val name: String? = null, + val description: String? = null, + val climbType: ClimbType, + val difficulty: DifficultyGrade, + val tags: List = emptyList(), + val location: String? = null, + val imagePaths: List? = null, + val isActive: Boolean = true, + val dateSet: String? = null, // ISO 8601 format + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupProblem from native Android Problem model */ + fun fromProblem(problem: Problem): BackupProblem { + return BackupProblem( + id = problem.id, + gymId = problem.gymId, + name = problem.name, + description = problem.description, + climbType = problem.climbType, + difficulty = problem.difficulty, + tags = problem.tags, + location = problem.location, + imagePaths = problem.imagePaths.ifEmpty { null }, + isActive = problem.isActive, + dateSet = problem.dateSet, + notes = problem.notes, + createdAt = problem.createdAt, + updatedAt = problem.updatedAt + ) + } + } + + /** Convert to native Android Problem model */ + fun toProblem(): Problem { + return Problem( + id = id, + gymId = gymId, + name = name, + description = description, + climbType = climbType, + difficulty = difficulty, + tags = tags, + location = location, + imagePaths = imagePaths ?: emptyList(), + isActive = isActive, + dateSet = dateSet, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } + + /** Create a copy with updated image paths for import processing */ + fun withUpdatedImagePaths(newImagePaths: List): BackupProblem { + return copy(imagePaths = newImagePaths.ifEmpty { null }) + } +} + +/** Platform-neutral climb session representation for backup/restore */ +@Serializable +data class BackupClimbSession( + val id: String, + val gymId: String, + val date: String, // ISO 8601 format + val startTime: String? = null, // ISO 8601 format + val endTime: String? = null, // ISO 8601 format + val duration: Long? = null, // Duration in seconds + val status: SessionStatus, + val notes: String? = null, + val createdAt: String, // ISO 8601 format + val updatedAt: String // ISO 8601 format +) { + companion object { + /** Create BackupClimbSession from native Android ClimbSession model */ + fun fromClimbSession(session: ClimbSession): BackupClimbSession { + return BackupClimbSession( + id = session.id, + gymId = session.gymId, + date = session.date, + startTime = session.startTime, + endTime = session.endTime, + duration = session.duration, + status = session.status, + notes = session.notes, + createdAt = session.createdAt, + updatedAt = session.updatedAt + ) + } + } + + /** Convert to native Android ClimbSession model */ + fun toClimbSession(): ClimbSession { + return ClimbSession( + id = id, + gymId = gymId, + date = date, + startTime = startTime, + endTime = endTime, + duration = duration, + status = status, + notes = notes, + createdAt = createdAt, + updatedAt = updatedAt + ) + } +} + +/** Platform-neutral attempt representation for backup/restore */ +@Serializable +data class BackupAttempt( + val id: String, + val sessionId: String, + val problemId: String, + val result: AttemptResult, + val highestHold: String? = null, + val notes: String? = null, + val duration: Long? = null, // Duration in seconds + val restTime: Long? = null, // Rest time in seconds + val timestamp: String, // ISO 8601 format + val createdAt: String // ISO 8601 format +) { + companion object { + /** Create BackupAttempt from native Android Attempt model */ + fun fromAttempt(attempt: Attempt): BackupAttempt { + return BackupAttempt( + id = attempt.id, + sessionId = attempt.sessionId, + problemId = attempt.problemId, + result = attempt.result, + highestHold = attempt.highestHold, + notes = attempt.notes, + duration = attempt.duration, + restTime = attempt.restTime, + timestamp = attempt.timestamp, + createdAt = attempt.createdAt + ) + } + } + + /** Convert to native Android Attempt model */ + fun toAttempt(): Attempt { + return Attempt( + id = id, + sessionId = sessionId, + problemId = problemId, + result = result, + highestHold = highestHold, + notes = notes, + duration = duration, + restTime = restTime, + timestamp = timestamp, + createdAt = createdAt + ) + } +} diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index e4d3e0d..37acff7 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -2,6 +2,11 @@ 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.utils.ZipExportImportUtils import java.io.File @@ -27,7 +32,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) - fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) // Problem operations fun getAllProblems(): Flow> = problemDao.getAllProblems() @@ -36,7 +40,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) - fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) // Session operations fun getAllSessions(): Flow> = sessionDao.getAllSessions() @@ -67,69 +70,9 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) 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 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 +81,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Validate data integrity before export validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) - val exportData = - ClimbDataExport( + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( exportedAt = LocalDateTime.now().toString(), 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 +105,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) imagePath ) imageFile.exists() && imageFile.length() > 0 - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -169,7 +114,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 +140,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) // Parse and validate the data structure val importData = try { - json.decodeFromString(importResult.jsonContent) + json.decodeFromString(importResult.jsonContent) } catch (e: Exception) { throw Exception("Invalid data format: ${e.message}") } @@ -210,44 +155,47 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) gymDao.deleteAllGyms() // Import gyms first (problems depend on gyms) - importData.gyms.forEach { gym -> + 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) + 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 -> + 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 -> + 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}") } } } catch (e: Exception) { @@ -291,7 +239,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") } @@ -339,13 +287,3 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } } } - -@kotlinx.serialization.Serializable -data class ClimbDataExport( - val exportedAt: String, - val version: String = "2.0", - val gyms: List, - val problems: List, - val sessions: List, - val attempts: List -) diff --git a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt index 9bd349b..a8ee1a6 100644 --- a/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt +++ b/android/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -1,7 +1,8 @@ package com.atridad.openclimb.utils import android.content.Context -import kotlinx.serialization.json.Json +import com.atridad.openclimb.data.format.BackupProblem +import com.atridad.openclimb.data.format.ClimbDataBackup import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -10,13 +11,15 @@ import java.time.LocalDateTime import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object ZipExportImportUtils { - + private const val DATA_JSON_FILENAME = "data.json" private const val IMAGES_DIR_NAME = "images" private const val METADATA_FILENAME = "metadata.txt" - + /** * Creates a ZIP file containing the JSON data and all referenced images * @param context Android context @@ -26,19 +29,26 @@ object ZipExportImportUtils { * @return The created ZIP file */ fun createExportZip( - context: Context, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set, - directory: File? = null + context: Context, + exportData: ClimbDataBackup, + referencedImagePaths: Set, + directory: File? = null ): File { - val exportDir = directory ?: File(context.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS), "OpenClimb") + val exportDir = + directory + ?: File( + context.getExternalFilesDir( + android.os.Environment.DIRECTORY_DOCUMENTS + ), + "OpenClimb" + ) if (!exportDir.exists()) { exportDir.mkdirs() } - + val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") - + try { ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> // Add metadata file first @@ -47,19 +57,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -68,31 +78,39 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } zipOut.closeEntry() successfulImages++ } else { - android.util.Log.w("ZipExportImportUtils", "Image file not found or empty: $imagePath") + android.util.Log.w( + "ZipExportImportUtils", + "Image file not found or empty: $imagePath" + ) } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - + // Log export summary - android.util.Log.i("ZipExportImportUtils", "Export completed: ${successfulImages}/${referencedImagePaths.size} images included") + android.util.Log.i( + "ZipExportImportUtils", + "Export completed: ${successfulImages}/${referencedImagePaths.size} images included" + ) } - + // Validate the created ZIP file if (!zipFile.exists() || zipFile.length() == 0L) { throw IOException("Failed to create ZIP file: file is empty or doesn't exist") } - + return zipFile - } catch (e: Exception) { // Clean up failed export if (zipFile.exists()) { @@ -101,7 +119,7 @@ object ZipExportImportUtils { throw IOException("Failed to create export ZIP: ${e.message}") } } - + /** * Creates a ZIP file and writes it to a provided URI * @param context Android context @@ -110,10 +128,10 @@ object ZipExportImportUtils { * @param referencedImagePaths Set of image paths referenced in the data */ fun createExportZipToUri( - context: Context, - uri: android.net.Uri, - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + context: Context, + uri: android.net.Uri, + exportData: ClimbDataBackup, + referencedImagePaths: Set ) { try { context.contentResolver.openOutputStream(uri)?.use { outputStream -> @@ -124,19 +142,19 @@ object ZipExportImportUtils { zipOut.putNextEntry(metadataEntry) zipOut.write(metadata.toByteArray()) zipOut.closeEntry() - + // Add JSON data file - val json = Json { - prettyPrint = true + val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - + val jsonString = json.encodeToString(exportData) + val jsonEntry = ZipEntry(DATA_JSON_FILENAME) zipOut.putNextEntry(jsonEntry) zipOut.write(jsonString.toByteArray()) zipOut.closeEntry() - + // Add images with validation var successfulImages = 0 referencedImagePaths.forEach { imagePath -> @@ -145,7 +163,7 @@ object ZipExportImportUtils { if (imageFile.exists() && imageFile.length() > 0) { val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") zipOut.putNextEntry(imageEntry) - + FileInputStream(imageFile).use { imageInput -> imageInput.copyTo(zipOut) } @@ -153,22 +171,28 @@ object ZipExportImportUtils { successfulImages++ } } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to add image $imagePath: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to add image $imagePath: ${e.message}" + ) } } - - android.util.Log.i("ZipExportImportUtils", "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included") + + android.util.Log.i( + "ZipExportImportUtils", + "Export to URI completed: ${successfulImages}/${referencedImagePaths.size} images included" + ) } - } ?: throw IOException("Could not open output stream") - + } + ?: throw IOException("Could not open output stream") } catch (e: Exception) { throw IOException("Failed to create export ZIP to URI: ${e.message}") } } - + private fun createMetadata( - exportData: com.atridad.openclimb.data.repository.ClimbDataExport, - referencedImagePaths: Set + exportData: ClimbDataBackup, + referencedImagePaths: Set ): String { return buildString { appendLine("OpenClimb Export Metadata") @@ -183,15 +207,13 @@ object ZipExportImportUtils { appendLine("Format: ZIP with embedded JSON data and images") } } - - /** - * Data class to hold extraction results - */ + + /** Data class to hold extraction results */ data class ImportResult( - val jsonContent: String, - val importedImagePaths: Map // original filename -> new relative path + val jsonContent: String, + val importedImagePaths: Map // original filename -> new relative path ) - + /** * Extracts a ZIP file and returns the JSON content and imported image paths * @param context Android context @@ -200,106 +222,125 @@ object ZipExportImportUtils { */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" - var metadataContent = "" val importedImagePaths = mutableMapOf() var foundRequiredFiles = mutableSetOf() - + try { ZipInputStream(FileInputStream(zipFile)).use { zipIn -> var entry = zipIn.nextEntry - + while (entry != null) { when { entry.name == METADATA_FILENAME -> { // Read metadata for validation - metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) + val metadataContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("metadata") - android.util.Log.i("ZipExportImportUtils", "Found metadata: ${metadataContent.lines().take(3).joinToString()}") + android.util.Log.i( + "ZipExportImportUtils", + "Found metadata: ${metadataContent.lines().take(3).joinToString()}" + ) } - entry.name == DATA_JSON_FILENAME -> { // Read JSON data jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) foundRequiredFiles.add("data") } - entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { // Extract image file val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") - + try { // Create temporary file to hold the extracted image - val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir) - - FileOutputStream(tempFile).use { output -> - zipIn.copyTo(output) - } - + val tempFile = + File.createTempFile( + "import_image_", + "_$originalFilename", + context.cacheDir + ) + + FileOutputStream(tempFile).use { output -> zipIn.copyTo(output) } + // Validate the extracted image if (tempFile.exists() && tempFile.length() > 0) { // Import the image to permanent storage val newPath = ImageUtils.importImageFile(context, tempFile) if (newPath != null) { importedImagePaths[originalFilename] = newPath - android.util.Log.d("ZipExportImportUtils", "Successfully imported image: $originalFilename -> $newPath") + android.util.Log.d( + "ZipExportImportUtils", + "Successfully imported image: $originalFilename -> $newPath" + ) } else { - android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename") + android.util.Log.w( + "ZipExportImportUtils", + "Failed to import image: $originalFilename" + ) } } else { - android.util.Log.w("ZipExportImportUtils", "Extracted image is empty: $originalFilename") + android.util.Log.w( + "ZipExportImportUtils", + "Extracted image is empty: $originalFilename" + ) } - + // Clean up temp file tempFile.delete() - } catch (e: Exception) { - android.util.Log.e("ZipExportImportUtils", "Failed to process image $originalFilename: ${e.message}") + android.util.Log.e( + "ZipExportImportUtils", + "Failed to process image $originalFilename: ${e.message}" + ) } } - else -> { - android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}") + android.util.Log.d( + "ZipExportImportUtils", + "Skipping ZIP entry: ${entry.name}" + ) } } - + zipIn.closeEntry() entry = zipIn.nextEntry } } - + // Validate that we found the required files if (!foundRequiredFiles.contains("data")) { throw IOException("Invalid ZIP file: data.json not found") } - + if (jsonContent.isBlank()) { throw IOException("Invalid ZIP file: data.json is empty") } - - android.util.Log.i("ZipExportImportUtils", "Import extraction completed: ${importedImagePaths.size} images processed") - + + android.util.Log.i( + "ZipExportImportUtils", + "Import extraction completed: ${importedImagePaths.size} images processed" + ) + return ImportResult(jsonContent, importedImagePaths) - } catch (e: Exception) { throw IOException("Failed to extract import ZIP: ${e.message}") } } /** - * Updates image paths in a problem list after import - * This function maps the old image paths to the new ones after import + * Updates image paths in a problem list after import This function maps the old image paths to + * the new ones after import */ fun updateProblemImagePaths( - problems: List, - imagePathMapping: Map - ): List { + problems: List, + imagePathMapping: Map + ): List { return problems.map { problem -> - val updatedImagePaths = problem.imagePaths.mapNotNull { oldPath -> - // Extract filename from the old path - val filename = oldPath.substringAfterLast("/") - imagePathMapping[filename] - } - problem.copy(imagePaths = updatedImagePaths) + val updatedImagePaths = + (problem.imagePaths ?: emptyList()).mapNotNull { oldPath -> + // Extract filename from the old path + val filename = oldPath.substringAfterLast("/") + imagePathMapping[filename] + } + problem.withUpdatedImagePaths(updatedImagePaths) } } } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index e708b1c..0000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/test_backup/ClimbRepository.kt b/android/test_backup/ClimbRepository.kt new file mode 100644 index 0000000..fe3483c --- /dev/null +++ b/android/test_backup/ClimbRepository.kt @@ -0,0 +1,383 @@ +package com.atridad.openclimb.data.repository + +import android.content.Context +import com.atridad.openclimb.data.database.OpenClimbDatabase +import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.model.* +import com.atridad.openclimb.utils.ZipExportImportUtils +import java.io.File +import java.time.LocalDateTime +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json + +class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { + private val gymDao = database.gymDao() + private val problemDao = database.problemDao() + private val sessionDao = database.climbSessionDao() + private val attemptDao = database.attemptDao() + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + + // Gym operations + fun getAllGyms(): Flow> = gymDao.getAllGyms() + suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id) + suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym) + suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym) + suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym) + fun searchGyms(query: String): Flow> = gymDao.searchGyms(query) + + // Problem operations + fun getAllProblems(): Flow> = problemDao.getAllProblems() + suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id) + fun getProblemsByGym(gymId: String): Flow> = problemDao.getProblemsByGym(gymId) + suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem) + suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem) + suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem) + fun searchProblems(query: String): Flow> = problemDao.searchProblems(query) + + // Session operations + fun getAllSessions(): Flow> = sessionDao.getAllSessions() + suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id) + fun getSessionsByGym(gymId: String): Flow> = + sessionDao.getSessionsByGym(gymId) + suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession() + fun getActiveSessionFlow(): Flow = sessionDao.getActiveSessionFlow() + suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session) + suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session) + suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session) + suspend fun getLastUsedGym(): Gym? { + val recentSessions = sessionDao.getRecentSessions(1).first() + return if (recentSessions.isNotEmpty()) { + getGymById(recentSessions.first().gymId) + } else { + null + } + } + + // Attempt operations + fun getAllAttempts(): Flow> = attemptDao.getAllAttempts() + fun getAttemptsBySession(sessionId: String): Flow> = + attemptDao.getAttemptsBySession(sessionId) + fun getAttemptsByProblem(problemId: String): Flow> = + attemptDao.getAttemptsByProblem(problemId) + suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt) + suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt) + suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt) + + // ZIP Export with images - Single format for reliability + suspend fun exportAllDataToZip(directory: File? = null): File { + return try { + // Collect all data with proper error handling + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + // Validate data integrity before export + validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) + + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + // Log any missing images for debugging + val missingImages = referencedImagePaths - validImagePaths + if (missingImages.isNotEmpty()) { + android.util.Log.w( + "ClimbRepository", + "Some referenced images are missing: $missingImages" + ) + } + + ZipExportImportUtils.createExportZip( + context = context, + exportData = backupData, + referencedImagePaths = validImagePaths, + directory = directory + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + suspend fun exportAllDataToZipUri(uri: android.net.Uri) { + try { + // Collect all data + val allGyms = gymDao.getAllGyms().first() + val allProblems = problemDao.getAllProblems().first() + val allSessions = sessionDao.getAllSessions().first() + val allAttempts = attemptDao.getAllAttempts().first() + + // Validate data integrity before export + validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts) + + // Create backup data using platform-neutral format + val backupData = + ClimbDataBackup( + exportedAt = LocalDateTime.now().toString(), + version = "2.0", + formatVersion = "2.0", + gyms = + allGyms.map { + com.atridad.openclimb.data.format.BackupGym.fromGym(it) + }, + problems = + allProblems.map { + com.atridad.openclimb.data.format.BackupProblem.fromProblem( + it + ) + }, + sessions = + allSessions.map { + com.atridad.openclimb.data.format.BackupClimbSession + .fromClimbSession(it) + }, + attempts = + allAttempts.map { + com.atridad.openclimb.data.format.BackupAttempt.fromAttempt( + it + ) + } + ) + + // Collect all referenced image paths and validate they exist + val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() + val validImagePaths = + referencedImagePaths + .filter { imagePath -> + try { + val imageFile = + com.atridad.openclimb.utils.ImageUtils.getImageFile( + context, + imagePath + ) + imageFile.exists() && imageFile.length() > 0 + } catch (e: Exception) { + false + } + } + .toSet() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = backupData, + referencedImagePaths = validImagePaths + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } + } + + suspend fun importDataFromZip(file: File) { + try { + // Validate the ZIP file + if (!file.exists() || file.length() == 0L) { + throw Exception("Invalid ZIP file: file is empty or doesn't exist") + } + + // Extract and validate the ZIP contents + val importResult = ZipExportImportUtils.extractImportZip(context, file) + + // Validate JSON content + if (importResult.jsonContent.isBlank()) { + throw Exception("Invalid ZIP file: no data.json found or empty content") + } + + // Parse and validate the data structure + val importData = + try { + json.decodeFromString(importResult.jsonContent) + } catch (e: Exception) { + throw Exception("Invalid data format: ${e.message}") + } + + // Validate data integrity + validateImportData(importData) + + // Clear existing data to avoid conflicts + attemptDao.deleteAllAttempts() + sessionDao.deleteAllSessions() + problemDao.deleteAllProblems() + gymDao.deleteAllGyms() + + // Import gyms first (problems depend on gyms) + importData.gyms.forEach { backupGym -> + try { + gymDao.insertGym(backupGym.toGym()) + } catch (e: Exception) { + throw Exception("Failed to import gym '${backupGym.name}': ${e.message}") + } + } + + // Import problems with updated image paths + val updatedBackupProblems = + ZipExportImportUtils.updateProblemImagePaths( + importData.problems, + importResult.importedImagePaths + ) + + // Import problems (depends on gyms) + updatedBackupProblems.forEach { backupProblem -> + try { + problemDao.insertProblem(backupProblem.toProblem()) + } catch (e: Exception) { + throw Exception( + "Failed to import problem '${backupProblem.name}': ${e.message}" + ) + } + } + + // Import sessions + importData.sessions.forEach { backupSession -> + try { + sessionDao.insertSession(backupSession.toClimbSession()) + } catch (e: Exception) { + throw Exception("Failed to import session '${backupSession.id}': ${e.message}") + } + } + + // Import attempts last (depends on problems and sessions) + importData.attempts.forEach { backupAttempt -> + try { + attemptDao.insertAttempt(backupAttempt.toAttempt()) + } catch (e: Exception) { + throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}") + } + } + } catch (e: Exception) { + throw Exception("Import failed: ${e.message}") + } + } + + private fun validateDataIntegrity( + gyms: List, + problems: List, + sessions: List, + attempts: List + ) { + // Validate that all problems reference valid gyms + val gymIds = gyms.map { it.id }.toSet() + val invalidProblems = problems.filter { it.gymId !in gymIds } + if (invalidProblems.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidProblems.size} problems reference non-existent gyms" + ) + } + + // Validate that all sessions reference valid gyms + val invalidSessions = sessions.filter { it.gymId !in gymIds } + if (invalidSessions.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms" + ) + } + + // Validate that all attempts reference valid problems and sessions + val problemIds = problems.map { it.id }.toSet() + val sessionIds = sessions.map { it.id }.toSet() + + val invalidAttempts = + attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds } + if (invalidAttempts.isNotEmpty()) { + throw Exception( + "Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions" + ) + } + } + + private fun validateImportData(importData: ClimbDataBackup) { + if (importData.gyms.isEmpty()) { + throw Exception("Import data is invalid: no gyms found") + } + + if (importData.version.isBlank()) { + throw Exception("Import data is invalid: no version information") + } + + // Check for reasonable data sizes to prevent malicious imports + if (importData.gyms.size > 1000 || + importData.problems.size > 10000 || + importData.sessions.size > 10000 || + importData.attempts.size > 100000 + ) { + throw Exception("Import data is too large: possible corruption or malicious file") + } + } + + suspend fun resetAllData() { + try { + // Clear all data from database + attemptDao.deleteAllAttempts() + sessionDao.deleteAllSessions() + problemDao.deleteAllProblems() + gymDao.deleteAllGyms() + + // Clear all images from storage + clearAllImages() + } catch (e: Exception) { + throw Exception("Reset failed: ${e.message}") + } + } + + private fun clearAllImages() { + try { + // Get the images directory + val imagesDir = File(context.filesDir, "images") + if (imagesDir.exists() && imagesDir.isDirectory) { + val deletedCount = imagesDir.listFiles()?.size ?: 0 + imagesDir.deleteRecursively() + android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files") + } + } catch (e: Exception) { + android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}") + } + } +} diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index f8e7793..0a60cb7 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -394,7 +394,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -414,7 +414,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -437,7 +437,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -457,7 +457,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -479,7 +479,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -490,7 +490,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -509,7 +509,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -520,7 +520,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 6acfc54..2f1b85e 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index d99571e..ea9c62f 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -100,7 +100,7 @@ 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() } } diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift new file mode 100644 index 0000000..1a0f95a --- /dev/null +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -0,0 +1,452 @@ +// +// BackupFormat.swift +// OpenClimb +// +// Created by OpenClimb Team on 2024-12-19. +// Copyright © 2024 OpenClimb. All rights reserved. +// + +import Foundation + +// MARK: - Backup Format Specification v2.0 +// Platform-neutral backup format for cross-platform compatibility +// This format ensures portability between iOS and Android while maintaining +// platform-specific implementations + +/// Root structure for OpenClimb backup data +struct ClimbDataBackup: Codable { + let exportedAt: String + let version: String + let formatVersion: String + let gyms: [BackupGym] + let problems: [BackupProblem] + let sessions: [BackupClimbSession] + let attempts: [BackupAttempt] + + init( + exportedAt: String, + version: String = "2.0", + formatVersion: String = "2.0", + gyms: [BackupGym], + problems: [BackupProblem], + sessions: [BackupClimbSession], + attempts: [BackupAttempt] + ) { + self.exportedAt = exportedAt + self.version = version + self.formatVersion = formatVersion + self.gyms = gyms + self.problems = problems + self.sessions = sessions + self.attempts = attempts + } +} + +/// Platform-neutral gym representation for backup/restore +struct BackupGym: Codable { + let id: String + let name: String + let location: String? + let supportedClimbTypes: [ClimbType] + let difficultySystems: [DifficultySystem] + let customDifficultyGrades: [String] + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Gym model + init(from gym: Gym) { + self.id = gym.id.uuidString + self.name = gym.name + self.location = gym.location + self.supportedClimbTypes = gym.supportedClimbTypes + self.difficultySystems = gym.difficultySystems + self.customDifficultyGrades = gym.customDifficultyGrades + self.notes = gym.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.createdAt = formatter.string(from: gym.createdAt) + self.updatedAt = formatter.string(from: gym.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + name: String, + location: String?, + supportedClimbTypes: [ClimbType], + difficultySystems: [DifficultySystem], + customDifficultyGrades: [String] = [], + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.name = name + self.location = location + self.supportedClimbTypes = supportedClimbTypes + self.difficultySystems = difficultySystems + self.customDifficultyGrades = customDifficultyGrades + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Gym model + func toGym() throws -> Gym { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + return Gym.fromImport( + id: uuid, + name: name, + location: location, + supportedClimbTypes: supportedClimbTypes, + difficultySystems: difficultySystems, + customDifficultyGrades: customDifficultyGrades, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral problem representation for backup/restore +struct BackupProblem: Codable { + let id: String + let gymId: String + let name: String? + let description: String? + let climbType: ClimbType + let difficulty: DifficultyGrade + let tags: [String] + let location: String? + let imagePaths: [String]? + let isActive: Bool + let dateSet: String? // ISO 8601 format + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS Problem model + init(from problem: Problem) { + self.id = problem.id.uuidString + self.gymId = problem.gymId.uuidString + self.name = problem.name + self.description = problem.description + self.climbType = problem.climbType + self.difficulty = problem.difficulty + self.tags = problem.tags + self.location = problem.location + self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths + self.isActive = problem.isActive + self.notes = problem.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.dateSet = problem.dateSet.map { formatter.string(from: $0) } + self.createdAt = formatter.string(from: problem.createdAt) + self.updatedAt = formatter.string(from: problem.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + name: String?, + description: String?, + climbType: ClimbType, + difficulty: DifficultyGrade, + tags: [String] = [], + location: String?, + imagePaths: [String]?, + isActive: Bool, + dateSet: String?, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.name = name + self.description = description + self.climbType = climbType + self.difficulty = difficulty + self.tags = tags + self.location = location + self.imagePaths = imagePaths + self.isActive = isActive + self.dateSet = dateSet + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS Problem model + func toProblem() throws -> Problem { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let dateSetDate = dateSet.flatMap { formatter.date(from: $0) } + + return Problem.fromImport( + id: uuid, + gymId: gymUuid, + name: name, + description: description, + climbType: climbType, + difficulty: difficulty, + tags: tags, + location: location, + imagePaths: imagePaths ?? [], + isActive: isActive, + dateSet: dateSetDate, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } + + /// Create a copy with updated image paths for import processing + func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem { + return BackupProblem( + id: self.id, + gymId: self.gymId, + name: self.name, + description: self.description, + climbType: self.climbType, + difficulty: self.difficulty, + tags: self.tags, + location: self.location, + imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, + isActive: self.isActive, + dateSet: self.dateSet, + notes: self.notes, + createdAt: self.createdAt, + updatedAt: self.updatedAt + ) + } +} + +/// Platform-neutral climb session representation for backup/restore +struct BackupClimbSession: Codable { + let id: String + let gymId: String + let date: String // ISO 8601 format + let startTime: String? // ISO 8601 format + let endTime: String? // ISO 8601 format + let duration: Int64? // Duration in seconds + let status: SessionStatus + let notes: String? + let createdAt: String // ISO 8601 format + let updatedAt: String // ISO 8601 format + + /// Initialize from native iOS ClimbSession model + init(from session: ClimbSession) { + self.id = session.id.uuidString + self.gymId = session.gymId.uuidString + self.status = session.status + self.notes = session.notes + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.date = formatter.string(from: session.date) + self.startTime = session.startTime.map { formatter.string(from: $0) } + self.endTime = session.endTime.map { formatter.string(from: $0) } + self.duration = session.duration.map { Int64($0) } + self.createdAt = formatter.string(from: session.createdAt) + self.updatedAt = formatter.string(from: session.updatedAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + gymId: String, + date: String, + startTime: String?, + endTime: String?, + duration: Int64?, + status: SessionStatus, + notes: String?, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.gymId = gymId + self.date = date + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.status = status + self.notes = notes + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Convert to native iOS ClimbSession model + func toClimbSession() throws -> ClimbSession { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let gymUuid = UUID(uuidString: gymId), + let dateValue = formatter.date(from: date), + let createdDate = formatter.date(from: createdAt), + let updatedDate = formatter.date(from: updatedAt) + else { + throw BackupError.invalidDateFormat + } + + let startTimeValue = startTime.flatMap { formatter.date(from: $0) } + let endTimeValue = endTime.flatMap { formatter.date(from: $0) } + let durationValue = duration.map { Int($0) } + + return ClimbSession.fromImport( + id: uuid, + gymId: gymUuid, + date: dateValue, + startTime: startTimeValue, + endTime: endTimeValue, + duration: durationValue, + status: status, + notes: notes, + createdAt: createdDate, + updatedAt: updatedDate + ) + } +} + +/// Platform-neutral attempt representation for backup/restore +struct BackupAttempt: Codable { + let id: String + let sessionId: String + let problemId: String + let result: AttemptResult + let highestHold: String? + let notes: String? + let duration: Int64? // Duration in seconds + let restTime: Int64? // Rest time in seconds + let timestamp: String // ISO 8601 format + let createdAt: String // ISO 8601 format + + /// Initialize from native iOS Attempt model + init(from attempt: Attempt) { + self.id = attempt.id.uuidString + self.sessionId = attempt.sessionId.uuidString + self.problemId = attempt.problemId.uuidString + self.result = attempt.result + self.highestHold = attempt.highestHold + self.notes = attempt.notes + self.duration = attempt.duration.map { Int64($0) } + self.restTime = attempt.restTime.map { Int64($0) } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.timestamp = formatter.string(from: attempt.timestamp) + self.createdAt = formatter.string(from: attempt.createdAt) + } + + /// Initialize with explicit parameters for import + init( + id: String, + sessionId: String, + problemId: String, + result: AttemptResult, + highestHold: String?, + notes: String?, + duration: Int64?, + restTime: Int64?, + timestamp: String, + createdAt: String + ) { + self.id = id + self.sessionId = sessionId + self.problemId = problemId + self.result = result + self.highestHold = highestHold + self.notes = notes + self.duration = duration + self.restTime = restTime + self.timestamp = timestamp + self.createdAt = createdAt + } + + /// Convert to native iOS Attempt model + func toAttempt() throws -> Attempt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let uuid = UUID(uuidString: id), + let sessionUuid = UUID(uuidString: sessionId), + let problemUuid = UUID(uuidString: problemId), + let timestampDate = formatter.date(from: timestamp), + let createdDate = formatter.date(from: createdAt) + else { + throw BackupError.invalidDateFormat + } + + let durationValue = duration.map { Int($0) } + let restTimeValue = restTime.map { Int($0) } + + return Attempt.fromImport( + id: uuid, + sessionId: sessionUuid, + problemId: problemUuid, + result: result, + highestHold: highestHold, + notes: notes, + duration: durationValue, + restTime: restTimeValue, + timestamp: timestampDate, + createdAt: createdDate + ) + } +} + +// MARK: - Backup Format Errors + +enum BackupError: LocalizedError { + case invalidDateFormat + case invalidUUID + case missingRequiredField(String) + case unsupportedFormatVersion(String) + + var errorDescription: String? { + switch self { + case .invalidDateFormat: + return "Invalid date format in backup data" + case .invalidUUID: + return "Invalid UUID format in backup data" + case .missingRequiredField(let field): + return "Missing required field: \(field)" + case .unsupportedFormatVersion(let version): + return "Unsupported backup format version: \(version)" + } + } +} + +// MARK: - Extensions + +// MARK: - Helper Extensions for Optional Mapping + +extension Optional { + func map(_ transform: (Wrapped) -> T) -> T? { + return self.flatMap { .some(transform($0)) } + } +} diff --git a/ios/OpenClimb/Utils/ZipUtils.swift b/ios/OpenClimb/Utils/ZipUtils.swift index ebdbd26..64d4fbd 100644 --- a/ios/OpenClimb/Utils/ZipUtils.swift +++ b/ios/OpenClimb/Utils/ZipUtils.swift @@ -1,4 +1,3 @@ - import Compression import Foundation import zlib @@ -10,7 +9,7 @@ struct ZipUtils { private static let METADATA_FILENAME = "metadata.txt" static func createExportZip( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) throws -> Data { @@ -196,7 +195,7 @@ struct ZipUtils { } private static func createMetadata( - exportData: ClimbDataExport, + exportData: ClimbDataBackup, referencedImagePaths: Set ) -> String { return """ diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 112f549..24719ec 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -473,13 +473,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 +530,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,10 +547,10 @@ 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() @@ -584,337 +585,6 @@ class ClimbingDataManager: ObservableObject { } } -struct ClimbDataExport: Codable { - let exportedAt: String - let version: String - let gyms: [AndroidGym] - let problems: [AndroidProblem] - let sessions: [AndroidClimbSession] - let attempts: [AndroidAttempt] - - init( - exportedAt: String, version: String = "2.0", gyms: [AndroidGym], problems: [AndroidProblem], - sessions: [AndroidClimbSession], attempts: [AndroidAttempt] - ) { - self.exportedAt = exportedAt - self.version = version - self.gyms = gyms - self.problems = problems - self.sessions = sessions - self.attempts = attempts - } -} - -struct AndroidGym: Codable { - let id: String - let name: String - let location: String? - let supportedClimbTypes: [ClimbType] - let difficultySystems: [DifficultySystem] - let customDifficultyGrades: [String] - let notes: String? - let createdAt: String - let updatedAt: String - - init(from gym: Gym) { - self.id = gym.id.uuidString - self.name = gym.name - self.location = gym.location - self.supportedClimbTypes = gym.supportedClimbTypes - self.difficultySystems = gym.difficultySystems - self.customDifficultyGrades = gym.customDifficultyGrades - self.notes = gym.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.createdAt = formatter.string(from: gym.createdAt) - self.updatedAt = formatter.string(from: gym.updatedAt) - } - - init( - id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], - difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [], - notes: String?, createdAt: String, updatedAt: String - ) { - self.id = id - self.name = name - self.location = location - self.supportedClimbTypes = supportedClimbTypes - self.difficultySystems = difficultySystems - self.customDifficultyGrades = customDifficultyGrades - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toGym() -> Gym { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let gymId = UUID(uuidString: id) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Gym.fromImport( - id: gymId, - name: name, - location: location, - supportedClimbTypes: supportedClimbTypes, - difficultySystems: difficultySystems, - customDifficultyGrades: customDifficultyGrades, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidProblem: Codable { - let id: String - let gymId: String - let name: String? - let description: String? - let climbType: ClimbType - let difficulty: DifficultyGrade - let tags: [String] - let location: String? - let imagePaths: [String]? - let isActive: Bool - let dateSet: String? - let notes: String? - let createdAt: String - let updatedAt: String - - init(from problem: Problem) { - self.id = problem.id.uuidString - self.gymId = problem.gymId.uuidString - self.name = problem.name - self.description = problem.description - self.climbType = problem.climbType - self.difficulty = problem.difficulty - self.tags = problem.tags - self.location = problem.location - self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths - self.isActive = problem.isActive - self.notes = problem.notes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.dateSet = problem.dateSet != nil ? formatter.string(from: problem.dateSet!) : nil - self.createdAt = formatter.string(from: problem.createdAt) - self.updatedAt = formatter.string(from: problem.updatedAt) - } - - init( - id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, - difficulty: DifficultyGrade, tags: [String] = [], - location: String? = nil, - imagePaths: [String]? = nil, isActive: Bool = true, dateSet: String? = nil, - notes: String? = nil, - createdAt: String, updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.name = name - self.description = description - self.climbType = climbType - self.difficulty = difficulty - self.tags = tags - self.location = location - self.imagePaths = imagePaths - self.isActive = isActive - self.dateSet = dateSet - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toProblem() -> Problem { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let problemId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return Problem.fromImport( - id: problemId, - gymId: preservedGymId, - name: name, - description: description, - climbType: climbType, - difficulty: difficulty, - tags: tags, - location: location, - imagePaths: imagePaths ?? [], - isActive: isActive, - dateSet: dateSet != nil ? formatter.date(from: dateSet!) : nil, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } - - func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem { - return AndroidProblem( - id: self.id, - gymId: self.gymId, - name: self.name, - description: self.description, - climbType: self.climbType, - difficulty: self.difficulty, - tags: self.tags, - location: self.location, - imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, - isActive: self.isActive, - dateSet: self.dateSet, - notes: self.notes, - createdAt: self.createdAt, - updatedAt: self.updatedAt - ) - } -} - -struct AndroidClimbSession: Codable { - let id: String - let gymId: String - let date: String - let startTime: String? - let endTime: String? - let duration: Int64? - let status: SessionStatus - let notes: String? - let createdAt: String - let updatedAt: String - - init(from session: ClimbSession) { - self.id = session.id.uuidString - self.gymId = session.gymId.uuidString - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.date = formatter.string(from: session.date) - self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil - self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil - self.duration = session.duration != nil ? Int64(session.duration!) : nil - self.status = session.status - self.notes = session.notes - self.createdAt = formatter.string(from: session.createdAt) - self.updatedAt = formatter.string(from: session.updatedAt) - } - - init( - id: String, gymId: String, date: String, startTime: String?, endTime: String?, - duration: Int64?, status: SessionStatus, notes: String? = nil, createdAt: String, - updatedAt: String - ) { - self.id = id - self.gymId = gymId - self.date = date - self.startTime = startTime - self.endTime = endTime - self.duration = duration - self.status = status - self.notes = notes - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - func toClimbSession() -> ClimbSession { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - // Preserve original IDs and dates - let sessionId = UUID(uuidString: id) ?? UUID() - let preservedGymId = UUID(uuidString: gymId) ?? UUID() - let sessionDate = formatter.date(from: date) ?? Date() - let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil - let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil - let createdDate = formatter.date(from: createdAt) ?? Date() - let updatedDate = formatter.date(from: updatedAt) ?? Date() - - return ClimbSession.fromImport( - id: sessionId, - gymId: preservedGymId, - date: sessionDate, - startTime: sessionStartTime, - endTime: sessionEndTime, - duration: duration != nil ? Int(duration!) : nil, - status: status, - notes: notes, - createdAt: createdDate, - updatedAt: updatedDate - ) - } -} - -struct AndroidAttempt: Codable { - let id: String - let sessionId: String - let problemId: String - let result: AttemptResult - let highestHold: String? - let notes: String? - let duration: Int64? - let restTime: Int64? - let timestamp: String - let createdAt: String - - init(from attempt: Attempt) { - self.id = attempt.id.uuidString - self.sessionId = attempt.sessionId.uuidString - self.problemId = attempt.problemId.uuidString - self.result = attempt.result - self.highestHold = attempt.highestHold - self.notes = attempt.notes - self.duration = attempt.duration != nil ? Int64(attempt.duration!) : nil - self.restTime = attempt.restTime != nil ? Int64(attempt.restTime!) : nil - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - self.timestamp = formatter.string(from: attempt.timestamp) - self.createdAt = formatter.string(from: attempt.createdAt) - } - - init( - id: String, sessionId: String, problemId: String, result: AttemptResult, - highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?, - timestamp: String, createdAt: String - ) { - self.id = id - self.sessionId = sessionId - self.problemId = problemId - self.result = result - self.highestHold = highestHold - self.notes = notes - self.duration = duration - self.restTime = restTime - self.timestamp = timestamp - self.createdAt = createdAt - } - - func toAttempt() -> Attempt { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - - let attemptId = UUID(uuidString: id) ?? UUID() - let preservedSessionId = UUID(uuidString: sessionId) ?? UUID() - let preservedProblemId = UUID(uuidString: problemId) ?? UUID() - let attemptTimestamp = formatter.date(from: timestamp) ?? Date() - let createdDate = formatter.date(from: createdAt) ?? Date() - - return Attempt.fromImport( - id: attemptId, - sessionId: preservedSessionId, - problemId: preservedProblemId, - result: result, - highestHold: highestHold, - notes: notes, - duration: duration != nil ? Int(duration!) : nil, - restTime: restTime != nil ? Int(restTime!) : nil, - timestamp: attemptTimestamp, - createdAt: createdDate - ) - } -} - extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() @@ -949,9 +619,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 +968,7 @@ extension ClimbingDataManager { saveAttempts() } - private func validateImportData(_ importData: ClimbDataExport) throws { + private func validateImportData(_ importData: ClimbDataBackup) throws { if importData.gyms.isEmpty { throw NSError( domain: "ImportError", code: 1, diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index c6e7992..4f68042 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -130,14 +130,8 @@ final class LiveActivityManager { completedProblems: completedProblems ) - do { - await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) - print("✅ Live Activity updated successfully") - } catch { - print("❌ Failed to update Live Activity: \(error)") - // If update fails, the activity might have been dismissed - self.currentActivity = nil - } + await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) + print("✅ Live Activity updated successfully") } /// Call this when a ClimbSession ends to end the Live Activity diff --git a/ios/OpenClimb/Views/SessionsView.swift b/ios/OpenClimb/Views/SessionsView.swift index a9df99b..0d759d3 100644 --- a/ios/OpenClimb/Views/SessionsView.swift +++ b/ios/OpenClimb/Views/SessionsView.swift @@ -168,15 +168,9 @@ struct ActiveSessionBanner: View { .onDisappear { stopTimer() } - .background( - NavigationLink( - destination: SessionDetailView(sessionId: session.id), - isActive: $navigateToDetail - ) { - EmptyView() - } - .hidden() - ) + .navigationDestination(isPresented: $navigateToDetail) { + SessionDetailView(sessionId: session.id) + } } private func formatDuration(from start: Date, to end: Date) -> String {