From f078cfc6e1a7e396e6f411c2dc1c79b3a6456994 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Fri, 22 Aug 2025 20:39:19 -0600 Subject: [PATCH] 1.1.0 - Export/Import overhaul --- app/build.gradle.kts | 2 +- .../openclimb/data/database/dao/AttemptDao.kt | 3 + .../data/database/dao/ClimbSessionDao.kt | 3 + .../openclimb/data/database/dao/GymDao.kt | 3 + .../openclimb/data/database/dao/ProblemDao.kt | 6 + .../data/repository/ClimbRepository.kt | 369 ++++++++++-------- .../openclimb/ui/screens/SettingsScreen.kt | 106 +++-- .../openclimb/ui/viewmodel/ClimbViewModel.kt | 48 +-- .../openclimb/utils/ZipExportImportUtils.kt | 294 +++++++++----- 9 files changed, 521 insertions(+), 313 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0563c7..8438f58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ android { minSdk = 33 targetSdk = 36 versionCode = 15 - versionName = "1.0.1" + versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt index 50792a0..75adde1 100644 --- a/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/AttemptDao.kt @@ -53,6 +53,9 @@ interface AttemptDao { @Query("SELECT COUNT(*) FROM attempts") suspend fun getAttemptsCount(): Int + @Query("DELETE FROM attempts") + suspend fun deleteAllAttempts() + @Query("SELECT COUNT(*) FROM attempts WHERE sessionId = :sessionId") suspend fun getAttemptsCountBySession(sessionId: String): Int diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt index 7f13b19..f42920d 100644 --- a/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/ClimbSessionDao.kt @@ -59,6 +59,9 @@ interface ClimbSessionDao { @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") suspend fun getActiveSession(): ClimbSession? + @Query("DELETE FROM climb_sessions") + suspend fun deleteAllSessions() + @Query("SELECT * FROM climb_sessions WHERE status = 'ACTIVE' ORDER BY date DESC LIMIT 1") fun getActiveSessionFlow(): Flow } diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt index 4e2a475..3bc9a81 100644 --- a/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/GymDao.kt @@ -37,4 +37,7 @@ interface GymDao { @Query("SELECT * FROM gyms WHERE name LIKE '%' || :searchQuery || '%' OR location LIKE '%' || :searchQuery || '%'") fun searchGyms(searchQuery: String): Flow> + + @Query("DELETE FROM gyms") + suspend fun deleteAllGyms() } diff --git a/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt b/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt index 851dd0b..19697e4 100644 --- a/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt +++ b/app/src/main/java/com/atridad/openclimb/data/database/dao/ProblemDao.kt @@ -59,4 +59,10 @@ interface ProblemDao { ORDER BY updatedAt DESC """) fun searchProblems(searchQuery: String): Flow> + + @Query("SELECT COUNT(*) FROM problems") + suspend fun getProblemsCount(): Int + + @Query("DELETE FROM problems") + suspend fun deleteAllProblems() } diff --git a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index c748e37..4a10d5c 100644 --- a/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -64,181 +64,148 @@ class ClimbRepository( - // JSON Export - suspend fun exportAllDataToJson(directory: File? = null): File { - val exportDir = directory ?: File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "OpenClimb") - if (!exportDir.exists()) { - exportDir.mkdirs() - } - - val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") - val exportFile = File(exportDir, "openclimb_export_$timestamp.json") - - val allGyms = gymDao.getAllGyms().first() - val allProblems = problemDao.getAllProblems().first() - val allSessions = sessionDao.getAllSessions().first() - val allAttempts = attemptDao.getAllAttempts().first() - - val exportData = ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts - ) - - val jsonString = json.encodeToString(exportData) - exportFile.writeText(jsonString) - - return exportFile - } - - suspend fun exportAllDataToUri(context: Context, uri: android.net.Uri) { - val gyms = gymDao.getAllGyms().first() - val problems = problemDao.getAllProblems().first() - val sessions = sessionDao.getAllSessions().first() - val attempts = attemptDao.getAllAttempts().first() - - val exportData = ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - gyms = gyms, - problems = problems, - sessions = sessions, - attempts = attempts - ) - - val jsonString = json.encodeToString(exportData) - - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(jsonString.toByteArray()) - } ?: throw Exception("Could not open output stream") - } - - suspend fun importDataFromJson(file: File) { - try { - val jsonContent = file.readText() - val importData = json.decodeFromString(jsonContent) - - // Import gyms - importData.gyms.forEach { gym -> - try { - gymDao.insertGym(gym) - } catch (_: Exception) { - // If insertion fails, update instead - gymDao.updateGym(gym) - } - } - - // Import problems - importData.problems.forEach { problem -> - try { - problemDao.insertProblem(problem) - } catch (_: Exception) { - problemDao.updateProblem(problem) - } - } - - // Import sessions - importData.sessions.forEach { session -> - try { - sessionDao.insertSession(session) - } catch (_: Exception) { - sessionDao.updateSession(session) - } - } - - // Import attempts - importData.attempts.forEach { attempt -> - try { - attemptDao.insertAttempt(attempt) - } catch (_: Exception) { - attemptDao.updateAttempt(attempt) - } - } - - } catch (e: Exception) { - throw Exception("Failed to import data: ${e.message}") - } - } - - // ZIP Export with images + // ZIP Export with images - Single format for reliability suspend fun exportAllDataToZip(directory: File? = null): File { - val allGyms = gymDao.getAllGyms().first() - val allProblems = problemDao.getAllProblems().first() - val allSessions = sessionDao.getAllSessions().first() - val allAttempts = attemptDao.getAllAttempts().first() - - val exportData = ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - gyms = allGyms, - problems = allProblems, - sessions = allSessions, - attempts = allAttempts - ) - - // Collect all referenced image paths - val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet() - - return ZipExportImportUtils.createExportZip( - context = context, - exportData = exportData, - referencedImagePaths = referencedImagePaths, - directory = directory - ) + 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 = "1.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) { - val gyms = gymDao.getAllGyms().first() - val problems = problemDao.getAllProblems().first() - val sessions = sessionDao.getAllSessions().first() - val attempts = attemptDao.getAllAttempts().first() - - val exportData = ClimbDataExport( - exportedAt = LocalDateTime.now().toString(), - gyms = gyms, - problems = problems, - sessions = sessions, - attempts = attempts - ) - - // Collect all image paths - val referencedImagePaths = problems.flatMap { it.imagePaths }.toSet() - - ZipExportImportUtils.createExportZipToUri( - context = context, - uri = uri, - exportData = exportData, - referencedImagePaths = referencedImagePaths - ) + 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 = "1.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() + + ZipExportImportUtils.createExportZipToUri( + context = context, + uri = uri, + exportData = exportData, + referencedImagePaths = validImagePaths + ) + } catch (e: Exception) { + throw Exception("Export failed: ${e.message}") + } } suspend fun importDataFromZip(file: File) { try { - val importResult = ZipExportImportUtils.extractImportZip(context, file) - val importData = json.decodeFromString(importResult.jsonContent) + // Validate the ZIP file + if (!file.exists() || file.length() == 0L) { + throw Exception("Invalid ZIP file: file is empty or doesn't exist") + } - // Update problem image paths with the new imported paths + // 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 { gym -> + try { + gymDao.insertGym(gym) + } catch (e: Exception) { + throw Exception("Failed to import gym ${gym.name}: ${e.message}") + } + } + + // Import problems with updated image paths val updatedProblems = ZipExportImportUtils.updateProblemImagePaths( importData.problems, importResult.importedImagePaths ) - // Import gyms - importData.gyms.forEach { gym -> - try { - gymDao.insertGym(gym) - } catch (e: Exception) { - // If insertion fails update instead - gymDao.updateGym(gym) - } - } - - // Import problems with updated image paths updatedProblems.forEach { problem -> try { problemDao.insertProblem(problem) } catch (e: Exception) { - problemDao.updateProblem(problem) + throw Exception("Failed to import problem ${problem.name}: ${e.message}") } } @@ -247,21 +214,100 @@ class ClimbRepository( try { sessionDao.insertSession(session) } catch (e: Exception) { - sessionDao.updateSession(session) + throw Exception("Failed to import session: ${e.message}") } } - // Import attempts + // Import attempts last (depends on problems and sessions) importData.attempts.forEach { attempt -> try { attemptDao.insertAttempt(attempt) } catch (e: Exception) { - attemptDao.updateAttempt(attempt) + throw Exception("Failed to import attempt: ${e.message}") } } } catch (e: Exception) { - throw Exception("Failed to import data: ${e.message}") + 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: ClimbDataExport) { + 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}") } } } @@ -269,6 +315,7 @@ class ClimbRepository( @kotlinx.serialization.Serializable data class ClimbDataExport( val exportedAt: String, + val version: String = "1.0", val gyms: List, val problems: List, val sessions: List, diff --git a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt index f25cff3..f87eb53 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/screens/SettingsScreen.kt @@ -26,12 +26,16 @@ fun SettingsScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + + // State for reset confirmation dialog + var showResetDialog by remember { mutableStateOf(false) } + val packageInfo = remember { context.packageManager.getPackageInfo(context.packageName, 0) } val appVersion = packageInfo.versionName - // File picker launcher for import - accepts both ZIP and JSON files + // File picker launcher for import - only accepts ZIP files val importLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> @@ -46,9 +50,13 @@ fun SettingsScreen( } else null } ?: "import_file" - val extension = fileName.substringAfterLast(".", "") - val tempFileName = if (extension.isNotEmpty()) "temp_import.$extension" else "temp_import" - val tempFile = File(context.cacheDir, tempFileName) + // Only allow ZIP files + if (!fileName.lowercase().endsWith(".zip")) { + viewModel.setError("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.") + return@let + } + + val tempFile = File(context.cacheDir, "temp_import.zip") inputStream?.use { input -> tempFile.outputStream().use { output -> @@ -62,7 +70,7 @@ fun SettingsScreen( } } - // File picker launcher for export - save location (ZIP format with images) + // File picker launcher for export - ZIP format with images val exportZipLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/zip") ) { uri -> @@ -75,19 +83,6 @@ fun SettingsScreen( } } - // JSON export launcher - val exportJsonLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/json") - ) { uri -> - uri?.let { - try { - viewModel.exportDataToUri(context, uri) - } catch (e: Exception) { - viewModel.setError("Failed to save file: ${e.message}") - } - } - } - LazyColumn( modifier = Modifier .fillMaxSize() @@ -179,19 +174,13 @@ fun SettingsScreen( ) ) { ListItem( - headlineContent = { Text("Export Data Only") }, - supportingContent = { Text("Export climbing data to JSON without images") }, - leadingContent = { Icon(Icons.Default.Share, contentDescription = null) }, + headlineContent = { Text("Import Data") }, + supportingContent = { Text("Import climbing data from ZIP file (recommended format)") }, + leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, trailingContent = { TextButton( onClick = { - val defaultFileName = "openclimb_export_${ - java.time.LocalDateTime.now() - .toString() - .replace(":", "-") - .replace(".", "-") - }.json" - exportJsonLauncher.launch(defaultFileName) + importLauncher.launch("application/zip") }, enabled = !uiState.isLoading ) { @@ -201,7 +190,7 @@ fun SettingsScreen( strokeWidth = 2.dp ) } else { - Text("Export JSON") + Text("Import") } } } @@ -213,17 +202,17 @@ fun SettingsScreen( Card( shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) ) ) { ListItem( - headlineContent = { Text("Import Data") }, - supportingContent = { Text("Import climbing data from ZIP or JSON file") }, - leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, + headlineContent = { Text("Reset All Data") }, + supportingContent = { Text("Permanently delete all gyms, problems, sessions, attempts, and images") }, + leadingContent = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, trailingContent = { TextButton( onClick = { - importLauncher.launch("*/*") + showResetDialog = true }, enabled = !uiState.isLoading ) { @@ -233,7 +222,7 @@ fun SettingsScreen( strokeWidth = 2.dp ) } else { - Text("Import") + Text("Reset", color = MaterialTheme.colorScheme.error) } } } @@ -390,4 +379,51 @@ fun SettingsScreen( } } } + + // Reset confirmation dialog + if (showResetDialog) { + AlertDialog( + onDismissRequest = { showResetDialog = false }, + title = { Text("Reset All Data") }, + text = { + Column { + Text("Are you sure you want to reset all data?") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This will permanently delete:", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "• All gyms and their information\n• All problems and their images\n• All climbing sessions\n• All attempts and progress data", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This action cannot be undone. Consider exporting your data first.", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + TextButton( + onClick = { + viewModel.resetAllData() + showResetDialog = false + } + ) { + Text("Reset All Data", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showResetDialog = false }) { + Text("Cancel") + } + } + ) + } } diff --git a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt index 01c9cfb..046ad87 100644 --- a/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt +++ b/app/src/main/java/com/atridad/openclimb/ui/viewmodel/ClimbViewModel.kt @@ -242,23 +242,7 @@ class ClimbViewModel( fun getAttemptsByProblem(problemId: String): Flow> = repository.getAttemptsByProblem(problemId) - fun exportDataToUri(context: Context, uri: android.net.Uri) { - viewModelScope.launch { - try { - _uiState.value = _uiState.value.copy(isLoading = true) - repository.exportAllDataToUri(context, uri) - _uiState.value = _uiState.value.copy( - isLoading = false, - message = "Data exported successfully" - ) - } catch (e: Exception) { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = "Export failed: ${e.message}" - ) - } - } - } + fun exportDataToZipUri(context: Context, uri: android.net.Uri) { viewModelScope.launch { @@ -283,13 +267,13 @@ class ClimbViewModel( try { _uiState.value = _uiState.value.copy(isLoading = true) - // Check if it's a ZIP file or JSON file - if (file.name.lowercase().endsWith(".zip")) { - repository.importDataFromZip(file) - } else { - repository.importDataFromJson(file) + // Only support ZIP format for reliability + if (!file.name.lowercase().endsWith(".zip")) { + throw Exception("Only ZIP files are supported for import. Please use a ZIP file exported from OpenClimb.") } + repository.importDataFromZip(file) + _uiState.value = _uiState.value.copy( isLoading = false, message = "Data imported successfully from ${file.name}" @@ -316,6 +300,26 @@ class ClimbViewModel( _uiState.value = _uiState.value.copy(error = message) } + fun resetAllData() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + + repository.resetAllData() + + _uiState.value = _uiState.value.copy( + isLoading = false, + message = "All data has been reset successfully" + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Reset failed: ${e.message}" + ) + } + } + } + // Share operations suspend fun generateSessionShareCard( context: Context, diff --git a/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt index 9c1f2eb..9bd349b 100644 --- a/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt +++ b/app/src/main/java/com/atridad/openclimb/utils/ZipExportImportUtils.kt @@ -15,6 +15,7 @@ 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 @@ -38,37 +39,67 @@ object ZipExportImportUtils { val timestamp = LocalDateTime.now().toString().replace(":", "-").replace(".", "-") val zipFile = File(exportDir, "openclimb_export_$timestamp.zip") - ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> - // Add JSON data file - val json = Json { prettyPrint = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - - val jsonEntry = ZipEntry(DATA_JSON_FILENAME) - zipOut.putNextEntry(jsonEntry) - zipOut.write(jsonString.toByteArray()) - zipOut.closeEntry() - - // Add images - referencedImagePaths.forEach { imagePath -> - try { - val imageFile = ImageUtils.getImageFile(context, imagePath) - if (imageFile.exists()) { - val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") - zipOut.putNextEntry(imageEntry) - - FileInputStream(imageFile).use { imageInput -> - imageInput.copyTo(zipOut) - } - zipOut.closeEntry() - } - } catch (e: Exception) { - // Log error but continue with other images - e.printStackTrace() + try { + ZipOutputStream(FileOutputStream(zipFile)).use { zipOut -> + // Add metadata file first + val metadata = createMetadata(exportData, referencedImagePaths) + val metadataEntry = ZipEntry(METADATA_FILENAME) + zipOut.putNextEntry(metadataEntry) + zipOut.write(metadata.toByteArray()) + zipOut.closeEntry() + + // Add JSON data file + val json = Json { + prettyPrint = true + ignoreUnknownKeys = true } + val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), 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 -> + try { + val imageFile = ImageUtils.getImageFile(context, imagePath) + 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") + } + } catch (e: Exception) { + 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") } + + // 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()) { + zipFile.delete() + } + throw IOException("Failed to create export ZIP: ${e.message}") } - - return zipFile } /** @@ -84,37 +115,73 @@ object ZipExportImportUtils { exportData: com.atridad.openclimb.data.repository.ClimbDataExport, referencedImagePaths: Set ) { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - ZipOutputStream(outputStream).use { zipOut -> - // Add JSON data file - val json = Json { prettyPrint = true } - val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), exportData) - - val jsonEntry = ZipEntry(DATA_JSON_FILENAME) - zipOut.putNextEntry(jsonEntry) - zipOut.write(jsonString.toByteArray()) - zipOut.closeEntry() - - // Add images - referencedImagePaths.forEach { imagePath -> - try { - val imageFile = ImageUtils.getImageFile(context, imagePath) - if (imageFile.exists()) { - val imageEntry = ZipEntry("$IMAGES_DIR_NAME/${imageFile.name}") - zipOut.putNextEntry(imageEntry) - - FileInputStream(imageFile).use { imageInput -> - imageInput.copyTo(zipOut) - } - zipOut.closeEntry() - } - } catch (e: Exception) { - // Log error but continue with other images - e.printStackTrace() + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + ZipOutputStream(outputStream).use { zipOut -> + // Add metadata file first + val metadata = createMetadata(exportData, referencedImagePaths) + val metadataEntry = ZipEntry(METADATA_FILENAME) + zipOut.putNextEntry(metadataEntry) + zipOut.write(metadata.toByteArray()) + zipOut.closeEntry() + + // Add JSON data file + val json = Json { + prettyPrint = true + ignoreUnknownKeys = true } + val jsonString = json.encodeToString(com.atridad.openclimb.data.repository.ClimbDataExport.serializer(), 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 -> + try { + val imageFile = ImageUtils.getImageFile(context, imagePath) + 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++ + } + } catch (e: Exception) { + 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") } - } - } ?: 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 { + return buildString { + appendLine("OpenClimb Export Metadata") + appendLine("=======================") + appendLine("Export Date: ${exportData.exportedAt}") + appendLine("Version: ${exportData.version}") + appendLine("Gyms: ${exportData.gyms.size}") + appendLine("Problems: ${exportData.problems.size}") + appendLine("Sessions: ${exportData.sessions.size}") + appendLine("Attempts: ${exportData.attempts.size}") + appendLine("Referenced Images: ${referencedImagePaths.size}") + appendLine("Format: ZIP with embedded JSON data and images") + } } /** @@ -133,50 +200,89 @@ object ZipExportImportUtils { */ fun extractImportZip(context: Context, zipFile: File): ImportResult { var jsonContent = "" + var metadataContent = "" val importedImagePaths = mutableMapOf() + var foundRequiredFiles = mutableSetOf() - ZipInputStream(FileInputStream(zipFile)).use { zipIn -> - var entry = zipIn.nextEntry - - while (entry != null) { - when { - entry.name == DATA_JSON_FILENAME -> { - // Read JSON data - jsonContent = zipIn.readBytes().toString(Charsets.UTF_8) + 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) + foundRequiredFiles.add("metadata") + 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) + } + + // 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") + } else { + android.util.Log.w("ZipExportImportUtils", "Failed to import image: $originalFilename") + } + } else { + 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}") + } + } + + else -> { + android.util.Log.d("ZipExportImportUtils", "Skipping ZIP entry: ${entry.name}") + } } - entry.name.startsWith("$IMAGES_DIR_NAME/") && !entry.isDirectory -> { - // Extract image file - val originalFilename = entry.name.substringAfter("$IMAGES_DIR_NAME/") - - // Create temporary file to hold the extracted image - val tempFile = File.createTempFile("import_image_", "_$originalFilename", context.cacheDir) - - FileOutputStream(tempFile).use { output -> - zipIn.copyTo(output) - } - - // Import the image to permanent storage - val newPath = ImageUtils.importImageFile(context, tempFile) - if (newPath != null) { - importedImagePaths[originalFilename] = newPath - } - - // Clean up temp file - tempFile.delete() - } + zipIn.closeEntry() + entry = zipIn.nextEntry } - - 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") + + return ImportResult(jsonContent, importedImagePaths) + + } catch (e: Exception) { + throw IOException("Failed to extract import ZIP: ${e.message}") } - - if (jsonContent.isEmpty()) { - throw IOException("No data.json file found in the ZIP archive") - } - - return ImportResult(jsonContent, importedImagePaths) } /**