1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import
formats :)
This commit is contained in:
383
android/test_backup/ClimbRepository.kt
Normal file
383
android/test_backup/ClimbRepository.kt
Normal file
@@ -0,0 +1,383 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
||||
private val gymDao = database.gymDao()
|
||||
private val problemDao = database.problemDao()
|
||||
private val sessionDao = database.climbSessionDao()
|
||||
private val attemptDao = database.attemptDao()
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
// Gym operations
|
||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||
suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym)
|
||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
sessionDao.getSessionsByGym(gymId)
|
||||
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
|
||||
suspend fun getLastUsedGym(): Gym? {
|
||||
val recentSessions = sessionDao.getRecentSessions(1).first()
|
||||
return if (recentSessions.isNotEmpty()) {
|
||||
getGymById(recentSessions.first().gymId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsByProblem(problemId)
|
||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||
|
||||
// ZIP Export with images - Single format for reliability
|
||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||
return try {
|
||||
// Collect all data with proper error handling
|
||||
val allGyms = gymDao.getAllGyms().first()
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
val allSessions = sessionDao.getAllSessions().first()
|
||||
val allAttempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
// Validate data integrity before export
|
||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||
|
||||
// Create backup data using platform-neutral format
|
||||
val backupData =
|
||||
ClimbDataBackup(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms =
|
||||
allGyms.map {
|
||||
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
|
||||
},
|
||||
problems =
|
||||
allProblems.map {
|
||||
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
|
||||
it
|
||||
)
|
||||
},
|
||||
sessions =
|
||||
allSessions.map {
|
||||
com.atridad.openclimb.data.format.BackupClimbSession
|
||||
.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
allAttempts.map {
|
||||
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
// Log any missing images for debugging
|
||||
val missingImages = referencedImagePaths - validImagePaths
|
||||
if (missingImages.isNotEmpty()) {
|
||||
android.util.Log.w(
|
||||
"ClimbRepository",
|
||||
"Some referenced images are missing: $missingImages"
|
||||
)
|
||||
}
|
||||
|
||||
ZipExportImportUtils.createExportZip(
|
||||
context = context,
|
||||
exportData = backupData,
|
||||
referencedImagePaths = validImagePaths,
|
||||
directory = directory
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportAllDataToZipUri(uri: android.net.Uri) {
|
||||
try {
|
||||
// Collect all data
|
||||
val allGyms = gymDao.getAllGyms().first()
|
||||
val allProblems = problemDao.getAllProblems().first()
|
||||
val allSessions = sessionDao.getAllSessions().first()
|
||||
val allAttempts = attemptDao.getAllAttempts().first()
|
||||
|
||||
// Validate data integrity before export
|
||||
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
|
||||
|
||||
// Create backup data using platform-neutral format
|
||||
val backupData =
|
||||
ClimbDataBackup(
|
||||
exportedAt = LocalDateTime.now().toString(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms =
|
||||
allGyms.map {
|
||||
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
|
||||
},
|
||||
problems =
|
||||
allProblems.map {
|
||||
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
|
||||
it
|
||||
)
|
||||
},
|
||||
sessions =
|
||||
allSessions.map {
|
||||
com.atridad.openclimb.data.format.BackupClimbSession
|
||||
.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
allAttempts.map {
|
||||
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
ZipExportImportUtils.createExportZipToUri(
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = backupData,
|
||||
referencedImagePaths = validImagePaths
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importDataFromZip(file: File) {
|
||||
try {
|
||||
// Validate the ZIP file
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
|
||||
}
|
||||
|
||||
// Extract and validate the ZIP contents
|
||||
val importResult = ZipExportImportUtils.extractImportZip(context, file)
|
||||
|
||||
// Validate JSON content
|
||||
if (importResult.jsonContent.isBlank()) {
|
||||
throw Exception("Invalid ZIP file: no data.json found or empty content")
|
||||
}
|
||||
|
||||
// Parse and validate the data structure
|
||||
val importData =
|
||||
try {
|
||||
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Invalid data format: ${e.message}")
|
||||
}
|
||||
|
||||
// Validate data integrity
|
||||
validateImportData(importData)
|
||||
|
||||
// Clear existing data to avoid conflicts
|
||||
attemptDao.deleteAllAttempts()
|
||||
sessionDao.deleteAllSessions()
|
||||
problemDao.deleteAllProblems()
|
||||
gymDao.deleteAllGyms()
|
||||
|
||||
// Import gyms first (problems depend on gyms)
|
||||
importData.gyms.forEach { backupGym ->
|
||||
try {
|
||||
gymDao.insertGym(backupGym.toGym())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Import problems with updated image paths
|
||||
val updatedBackupProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
// Import problems (depends on gyms)
|
||||
updatedBackupProblems.forEach { backupProblem ->
|
||||
try {
|
||||
problemDao.insertProblem(backupProblem.toProblem())
|
||||
} catch (e: Exception) {
|
||||
throw Exception(
|
||||
"Failed to import problem '${backupProblem.name}': ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Import sessions
|
||||
importData.sessions.forEach { backupSession ->
|
||||
try {
|
||||
sessionDao.insertSession(backupSession.toClimbSession())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Import attempts last (depends on problems and sessions)
|
||||
importData.attempts.forEach { backupAttempt ->
|
||||
try {
|
||||
attemptDao.insertAttempt(backupAttempt.toAttempt())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Import failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
sessions: List<ClimbSession>,
|
||||
attempts: List<Attempt>
|
||||
) {
|
||||
// Validate that all problems reference valid gyms
|
||||
val gymIds = gyms.map { it.id }.toSet()
|
||||
val invalidProblems = problems.filter { it.gymId !in gymIds }
|
||||
if (invalidProblems.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all sessions reference valid gyms
|
||||
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||
if (invalidSessions.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all attempts reference valid problems and sessions
|
||||
val problemIds = problems.map { it.id }.toSet()
|
||||
val sessionIds = sessions.map { it.id }.toSet()
|
||||
|
||||
val invalidAttempts =
|
||||
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||
if (invalidAttempts.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateImportData(importData: ClimbDataBackup) {
|
||||
if (importData.gyms.isEmpty()) {
|
||||
throw Exception("Import data is invalid: no gyms found")
|
||||
}
|
||||
|
||||
if (importData.version.isBlank()) {
|
||||
throw Exception("Import data is invalid: no version information")
|
||||
}
|
||||
|
||||
// Check for reasonable data sizes to prevent malicious imports
|
||||
if (importData.gyms.size > 1000 ||
|
||||
importData.problems.size > 10000 ||
|
||||
importData.sessions.size > 10000 ||
|
||||
importData.attempts.size > 100000
|
||||
) {
|
||||
throw Exception("Import data is too large: possible corruption or malicious file")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetAllData() {
|
||||
try {
|
||||
// Clear all data from database
|
||||
attemptDao.deleteAllAttempts()
|
||||
sessionDao.deleteAllSessions()
|
||||
problemDao.deleteAllProblems()
|
||||
gymDao.deleteAllGyms()
|
||||
|
||||
// Clear all images from storage
|
||||
clearAllImages()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Reset failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAllImages() {
|
||||
try {
|
||||
// Get the images directory
|
||||
val imagesDir = File(context.filesDir, "images")
|
||||
if (imagesDir.exists() && imagesDir.isDirectory) {
|
||||
val deletedCount = imagesDir.listFiles()?.size ?: 0
|
||||
imagesDir.deleteRecursively()
|
||||
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user