384 lines
16 KiB
Kotlin
384 lines
16 KiB
Kotlin
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}")
|
|
}
|
|
}
|
|
}
|