Cleanup
This commit is contained in:
@@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Root structure for OpenClimb backup data */
|
||||
// Root structure for OpenClimb backup data
|
||||
@Serializable
|
||||
data class ClimbDataBackup(
|
||||
val exportedAt: String,
|
||||
@@ -15,7 +15,7 @@ data class ClimbDataBackup(
|
||||
val attempts: List<BackupAttempt>
|
||||
)
|
||||
|
||||
/** Platform-neutral gym representation for backup/restore */
|
||||
// Platform-neutral gym representation for backup/restore
|
||||
@Serializable
|
||||
data class BackupGym(
|
||||
val id: String,
|
||||
@@ -26,8 +26,8 @@ data class BackupGym(
|
||||
@kotlinx.serialization.SerialName("customDifficultyGrades")
|
||||
val customDifficultyGrades: List<String> = emptyList(),
|
||||
val notes: String? = null,
|
||||
val createdAt: String, // ISO 8601 format
|
||||
val updatedAt: String // ISO 8601 format
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
/** Create BackupGym from native Android Gym model */
|
||||
@@ -62,7 +62,7 @@ data class BackupGym(
|
||||
}
|
||||
}
|
||||
|
||||
/** Platform-neutral problem representation for backup/restore */
|
||||
// Platform-neutral problem representation for backup/restore
|
||||
@Serializable
|
||||
data class BackupProblem(
|
||||
val id: String,
|
||||
@@ -75,10 +75,10 @@ data class BackupProblem(
|
||||
val location: String? = null,
|
||||
val imagePaths: List<String>? = null,
|
||||
val isActive: Boolean = true,
|
||||
val dateSet: String? = null, // ISO 8601 format
|
||||
val dateSet: String? = null,
|
||||
val notes: String? = null,
|
||||
val createdAt: String, // ISO 8601 format
|
||||
val updatedAt: String // ISO 8601 format
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
/** Create BackupProblem from native Android Problem model */
|
||||
@@ -94,11 +94,7 @@ data class BackupProblem(
|
||||
location = problem.location,
|
||||
imagePaths =
|
||||
if (problem.imagePaths.isEmpty()) null
|
||||
else
|
||||
problem.imagePaths.map { path ->
|
||||
// Store just the filename to match iOS format
|
||||
path.substringAfterLast('/')
|
||||
},
|
||||
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
|
||||
isActive = problem.isActive,
|
||||
dateSet = problem.dateSet,
|
||||
notes = problem.notes,
|
||||
@@ -134,19 +130,19 @@ data class BackupProblem(
|
||||
}
|
||||
}
|
||||
|
||||
/** Platform-neutral climb session representation for backup/restore */
|
||||
// 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 date: String,
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null,
|
||||
val duration: Long? = null,
|
||||
val status: SessionStatus,
|
||||
val notes: String? = null,
|
||||
val createdAt: String, // ISO 8601 format
|
||||
val updatedAt: String // ISO 8601 format
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
/** Create BackupClimbSession from native Android ClimbSession model */
|
||||
@@ -183,7 +179,7 @@ data class BackupClimbSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Platform-neutral attempt representation for backup/restore */
|
||||
// Platform-neutral attempt representation for backup/restore
|
||||
@Serializable
|
||||
data class BackupAttempt(
|
||||
val id: String,
|
||||
@@ -192,10 +188,11 @@ data class BackupAttempt(
|
||||
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
|
||||
val duration: Long? = null,
|
||||
val restTime: Long? = null,
|
||||
val timestamp: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
) {
|
||||
companion object {
|
||||
/** Create BackupAttempt from native Android Attempt model */
|
||||
|
||||
@@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository
|
||||
migrationResults.putAll(problemMigrations)
|
||||
migratedCount += problemMigrations.size
|
||||
|
||||
// Update problem with new image paths
|
||||
// Update image paths
|
||||
val newImagePaths =
|
||||
problem.imagePaths.map { oldPath ->
|
||||
problemMigrations[oldPath] ?: oldPath
|
||||
@@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if filename follows our convention
|
||||
// Check if filename follows convention
|
||||
if (ImageNamingUtils.isValidImageFilename(filename)) {
|
||||
validImages.add(imagePath)
|
||||
} else {
|
||||
|
||||
@@ -39,11 +39,11 @@ data class Attempt(
|
||||
val sessionId: String,
|
||||
val problemId: String,
|
||||
val result: AttemptResult,
|
||||
val highestHold: String? = null, // Description of the highest hold reached
|
||||
val highestHold: String? = null,
|
||||
val notes: String? = null,
|
||||
val duration: Long? = null, // Attempt duration in seconds
|
||||
val restTime: Long? = null, // Rest time before this attempt in seconds
|
||||
val timestamp: String, // When this attempt was made
|
||||
val duration: Long? = null,
|
||||
val restTime: Long? = null,
|
||||
val timestamp: String,
|
||||
val createdAt: String
|
||||
) {
|
||||
companion object {
|
||||
|
||||
@@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
enum class DifficultySystem {
|
||||
// Bouldering
|
||||
V_SCALE, // V-Scale (VB - V17)
|
||||
FONT, // Fontainebleau (3 - 8C+)
|
||||
V_SCALE,
|
||||
FONT,
|
||||
|
||||
// Rope
|
||||
YDS, // Yosemite Decimal System (5.0 - 5.15d)
|
||||
|
||||
// Custom difficulty systems
|
||||
YDS,
|
||||
CUSTOM;
|
||||
|
||||
/** Get the display name for the UI */
|
||||
@@ -28,7 +26,7 @@ enum class DifficultySystem {
|
||||
when (this) {
|
||||
V_SCALE, FONT -> true
|
||||
YDS -> false
|
||||
CUSTOM -> true // Custom is available for all
|
||||
CUSTOM -> true
|
||||
}
|
||||
|
||||
/** Check if this system is for rope climbing */
|
||||
@@ -157,7 +155,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
|
||||
}
|
||||
DifficultySystem.YDS -> {
|
||||
// Simplified numeric mapping for YDS grades
|
||||
when {
|
||||
grade.startsWith("5.10") ->
|
||||
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
|
||||
@@ -175,7 +172,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
}
|
||||
}
|
||||
DifficultySystem.FONT -> {
|
||||
// Simplified Font grade mapping
|
||||
when {
|
||||
grade.startsWith("6A") -> 6
|
||||
grade.startsWith("6B") -> 7
|
||||
@@ -209,24 +205,20 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
|
||||
}
|
||||
|
||||
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
|
||||
// Handle VB (easiest) specially
|
||||
if (grade1 == "VB" && grade2 != "VB") return -1
|
||||
if (grade2 == "VB" && grade1 != "VB") return 1
|
||||
if (grade1 == "VB" && grade2 == "VB") return 0
|
||||
|
||||
// Extract numeric values for V grades
|
||||
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
|
||||
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
|
||||
return num1.compareTo(num2)
|
||||
}
|
||||
|
||||
private fun compareFontGrades(grade1: String, grade2: String): Int {
|
||||
// Simple string comparison for Font grades
|
||||
return grade1.compareTo(grade2)
|
||||
}
|
||||
|
||||
private fun compareYDSGrades(grade1: String, grade2: String): Int {
|
||||
// Simple string comparison for YDS grades
|
||||
return grade1.compareTo(grade2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
private val attemptDao = database.attemptDao()
|
||||
private val dataStateManager = DataStateManager(context)
|
||||
|
||||
// Callback interface for auto-sync functionality
|
||||
private var autoSyncCallback: (() -> Unit)? = null
|
||||
|
||||
private val json = Json {
|
||||
@@ -125,16 +124,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
|
||||
suspend fun exportAllDataToZipUri(context: Context, 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 = DateFormatUtils.nowISO8601(),
|
||||
@@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
@@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
|
||||
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)
|
||||
@@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
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) - use DAO directly to avoid multiple data
|
||||
// state updates
|
||||
importData.gyms.forEach { backupGym ->
|
||||
try {
|
||||
gymDao.insertGym(backupGym.toGym())
|
||||
@@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
// Import problems with updated image paths
|
||||
val updatedBackupProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
// Import problems (depends on gyms) - use DAO directly
|
||||
updatedBackupProblems.forEach { backupProblem ->
|
||||
try {
|
||||
problemDao.insertProblem(backupProblem.toProblem())
|
||||
@@ -235,7 +220,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
// Import sessions - use DAO directly
|
||||
importData.sessions.forEach { backupSession ->
|
||||
try {
|
||||
sessionDao.insertSession(backupSession.toClimbSession())
|
||||
@@ -244,7 +228,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
// Import attempts last (depends on problems and sessions) - use DAO directly
|
||||
importData.attempts.forEach { backupAttempt ->
|
||||
try {
|
||||
attemptDao.insertAttempt(backupAttempt.toAttempt())
|
||||
@@ -253,7 +236,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
}
|
||||
}
|
||||
|
||||
// Update data state once at the end to current time since we just imported new data
|
||||
dataStateManager.updateDataState()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Import failed: ${e.message}")
|
||||
@@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
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()) {
|
||||
@@ -291,7 +272,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all sessions reference valid gyms
|
||||
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||
if (invalidSessions.isNotEmpty()) {
|
||||
throw Exception(
|
||||
@@ -299,7 +279,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all attempts reference valid problems and sessions
|
||||
val problemIds = problems.map { it.id }.toSet()
|
||||
val sessionIds = sessions.map { it.id }.toSet()
|
||||
|
||||
@@ -321,7 +300,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
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 ||
|
||||
@@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
|
||||
suspend fun resetAllData() {
|
||||
try {
|
||||
// Temporarily disable auto-sync during reset
|
||||
val originalCallback = autoSyncCallback
|
||||
autoSyncCallback = null
|
||||
|
||||
// Clear all data from database
|
||||
attemptDao.deleteAllAttempts()
|
||||
sessionDao.deleteAllSessions()
|
||||
problemDao.deleteAllProblems()
|
||||
gymDao.deleteAllGyms()
|
||||
|
||||
// Clear all images from storage
|
||||
clearAllImages()
|
||||
|
||||
// Restore auto-sync callback
|
||||
autoSyncCallback = originalCallback
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Reset failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
|
||||
suspend fun insertGymWithoutSync(gym: Gym) {
|
||||
gymDao.insertGym(gym)
|
||||
dataStateManager.updateDataState()
|
||||
@@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,6 @@ class DataStateManager(context: Context) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
// Initialize with current timestamp if this is the first time
|
||||
if (!isInitialized()) {
|
||||
updateDataState()
|
||||
markAsInitialized()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.atridad.openclimb.data.format.BackupAttempt
|
||||
import com.atridad.openclimb.data.format.BackupClimbSession
|
||||
import com.atridad.openclimb.data.format.BackupGym
|
||||
@@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import androidx.core.content.edit
|
||||
|
||||
class SyncService(private val context: Context, private val repository: ClimbRepository) {
|
||||
|
||||
@@ -61,7 +61,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
// State flows
|
||||
// State
|
||||
private val _isSyncing = MutableStateFlow(false)
|
||||
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||
|
||||
@@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
|
||||
init {
|
||||
// Initialize state from preferences
|
||||
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
|
||||
// Register auto-sync callback with repository
|
||||
repository.setAutoSyncCallback {
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
triggerAutoSync()
|
||||
}
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +149,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
"Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}"
|
||||
)
|
||||
|
||||
// Log problems with images
|
||||
backup.problems.forEach { problem ->
|
||||
val imageCount = problem.imagePaths?.size ?: 0
|
||||
if (imageCount > 0) {
|
||||
@@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
throw SyncException.NotConfigured
|
||||
}
|
||||
|
||||
// Server expects filename as query parameter and raw image data in body
|
||||
// Extract just the filename without directory path
|
||||
val justFilename = filename.substringAfterLast('/')
|
||||
val requestBody = imageData.toRequestBody("image/*".toMediaType())
|
||||
|
||||
@@ -252,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val response = httpClient.newCall(request).execute()
|
||||
|
||||
when (response.code) {
|
||||
200 -> Unit // Success
|
||||
200 -> Unit
|
||||
401 -> throw SyncException.Unauthorized
|
||||
else -> {
|
||||
val errorBody = response.body?.string() ?: "No error details"
|
||||
@@ -325,33 +318,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
throw SyncException.NotConnected
|
||||
}
|
||||
|
||||
// Prevent concurrent sync operations
|
||||
syncMutex.withLock {
|
||||
_isSyncing.value = true
|
||||
_syncError.value = null
|
||||
|
||||
try {
|
||||
// Fix existing image paths first
|
||||
Log.d(TAG, "Fixing existing image paths before sync")
|
||||
val pathFixSuccess = fixImagePaths()
|
||||
if (!pathFixSuccess) {
|
||||
Log.w(TAG, "Image path fix failed, but continuing with sync")
|
||||
}
|
||||
|
||||
// Migrate images to consistent naming second
|
||||
Log.d(TAG, "Performing image migration before sync")
|
||||
val migrationSuccess = migrateImagesForSync()
|
||||
if (!migrationSuccess) {
|
||||
Log.w(TAG, "Image migration failed, but continuing with sync")
|
||||
}
|
||||
|
||||
// Get local backup data
|
||||
val localBackup = createBackupFromRepository()
|
||||
|
||||
// Download server data
|
||||
val serverBackup = downloadData()
|
||||
|
||||
// Check if we have any local data
|
||||
val hasLocalData =
|
||||
localBackup.gyms.isNotEmpty() ||
|
||||
localBackup.problems.isNotEmpty() ||
|
||||
@@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
when {
|
||||
!hasLocalData && hasServerData -> {
|
||||
// Case 1: No local data - do full restore from server
|
||||
Log.d(TAG, "No local data found, performing full restore from server")
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
Log.d(TAG, "Full restore completed")
|
||||
}
|
||||
hasLocalData && !hasServerData -> {
|
||||
// Case 2: No server data - upload local data to server
|
||||
Log.d(TAG, "No server data found, uploading local data to server")
|
||||
uploadData(localBackup)
|
||||
syncImagesForBackup(localBackup)
|
||||
Log.d(TAG, "Initial upload completed")
|
||||
}
|
||||
hasLocalData && hasServerData -> {
|
||||
// Case 3: Both have data - compare timestamps (last writer wins)
|
||||
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
|
||||
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
|
||||
|
||||
@@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
)
|
||||
|
||||
if (localTimestamp > serverTimestamp) {
|
||||
// Local is newer - replace server with local data
|
||||
Log.d(TAG, "Local data is newer, replacing server content")
|
||||
uploadData(localBackup)
|
||||
syncImagesForBackup(localBackup)
|
||||
Log.d(TAG, "Server replaced with local data")
|
||||
} else if (serverTimestamp > localTimestamp) {
|
||||
// Server is newer - replace local with server data
|
||||
Log.d(TAG, "Server data is newer, replacing local content")
|
||||
val imagePathMapping = syncImagesFromServer(serverBackup)
|
||||
importBackupToRepository(serverBackup, imagePathMapping)
|
||||
Log.d(TAG, "Local data replaced with server data")
|
||||
} else {
|
||||
// Timestamps are equal - no sync needed
|
||||
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
|
||||
}
|
||||
}
|
||||
@@ -411,7 +392,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
val now = DateFormatUtils.nowISO8601()
|
||||
_lastSyncTime.value = now
|
||||
sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply()
|
||||
@@ -447,13 +427,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
Log.d(TAG, "Attempting to download image: $imagePath")
|
||||
val imageData = downloadImage(imagePath)
|
||||
|
||||
// Extract filename and ensure it follows our naming convention
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
val consistentFilename =
|
||||
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
|
||||
serverFilename
|
||||
} else {
|
||||
// Generate consistent filename using problem ID and index
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
}
|
||||
|
||||
@@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
)
|
||||
|
||||
if (localImagePath != null) {
|
||||
// Map original server filename to the full local relative path
|
||||
imagePathMapping[serverFilename] = localImagePath
|
||||
downloadedImages++
|
||||
Log.d(
|
||||
@@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val imageData = imageFile.readBytes()
|
||||
val filename = imagePath.substringAfterLast('/')
|
||||
|
||||
// Ensure filename follows our naming convention
|
||||
val consistentFilename =
|
||||
if (ImageNamingUtils.isValidImageFilename(filename)) {
|
||||
filename
|
||||
} else {
|
||||
// Generate consistent filename and rename the local file
|
||||
val newFilename =
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problem.id,
|
||||
@@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
TAG,
|
||||
"Renamed local image file: $filename -> $newFilename"
|
||||
)
|
||||
// Update the problem's image path in memory for next sync
|
||||
newFilename
|
||||
} else {
|
||||
Log.w(
|
||||
@@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
backup: ClimbDataBackup,
|
||||
imagePathMapping: Map<String, String> = emptyMap()
|
||||
) {
|
||||
// Clear existing data to avoid conflicts
|
||||
repository.resetAllData()
|
||||
|
||||
// Import gyms first (problems depend on gyms)
|
||||
backup.gyms.forEach { backupGym ->
|
||||
try {
|
||||
val gym = backupGym.toGym()
|
||||
@@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
repository.insertGymWithoutSync(gym)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
|
||||
throw e // Stop import if gym fails since problems depend on it
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Import problems with updated image paths
|
||||
backup.problems.forEach { backupProblem ->
|
||||
try {
|
||||
val updatedProblem =
|
||||
if (imagePathMapping.isNotEmpty()) {
|
||||
val newImagePaths =
|
||||
backupProblem.imagePaths?.map { oldPath ->
|
||||
// Extract filename and check mapping
|
||||
val filename = oldPath.substringAfterLast('/')
|
||||
// Use mapped full path or fallback to consistent naming
|
||||
// with full path
|
||||
|
||||
imagePathMapping[filename]
|
||||
?: if (ImageNamingUtils.isValidImageFilename(
|
||||
filename
|
||||
@@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
) {
|
||||
"problem_images/$filename"
|
||||
} else {
|
||||
// Generate consistent filename as fallback with
|
||||
// full path
|
||||
val index =
|
||||
backupProblem.imagePaths.indexOf(
|
||||
oldPath
|
||||
@@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Import sessions
|
||||
backup.sessions.forEach { backupSession ->
|
||||
try {
|
||||
repository.insertSessionWithoutSync(backupSession.toClimbSession())
|
||||
@@ -656,7 +622,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Import attempts last
|
||||
backup.attempts.forEach { backupAttempt ->
|
||||
try {
|
||||
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
|
||||
@@ -665,7 +630,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
}
|
||||
|
||||
// Update local data state to match imported data timestamp
|
||||
dataStateManager.setLastModified(backup.exportedAt)
|
||||
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
|
||||
}
|
||||
@@ -697,7 +661,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val fixedPaths =
|
||||
problem.imagePaths.map { path ->
|
||||
if (!path.startsWith("problem_images/") && !path.contains("/")) {
|
||||
// Just a filename, add the directory prefix
|
||||
val fixedPath = "problem_images/$path"
|
||||
Log.d(TAG, "Fixed path: $path -> $fixedPath")
|
||||
fixedCount++
|
||||
@@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
return
|
||||
}
|
||||
|
||||
// Check if sync is already running to prevent duplicate attempts
|
||||
if (_isSyncing.value) {
|
||||
Log.d(TAG, "Sync already in progress, skipping auto-sync")
|
||||
return
|
||||
|
||||
@@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class Screen {
|
||||
@Serializable
|
||||
data object Sessions : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Problems : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Analytics : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Gyms : Screen()
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen()
|
||||
|
||||
// Detail screens
|
||||
@Serializable
|
||||
data class SessionDetail(val sessionId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class ProblemDetail(val problemId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class GymDetail(val gymId: String) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AddEditGym(val gymId: String? = null) : Screen()
|
||||
|
||||
@Serializable data object Sessions : Screen()
|
||||
|
||||
@Serializable data object Problems : Screen()
|
||||
|
||||
@Serializable data object Analytics : Screen()
|
||||
|
||||
@Serializable data object Gyms : Screen()
|
||||
|
||||
@Serializable data object Settings : Screen()
|
||||
|
||||
@Serializable data class SessionDetail(val sessionId: String) : Screen()
|
||||
|
||||
@Serializable data class ProblemDetail(val problemId: String) : Screen()
|
||||
|
||||
@Serializable data class GymDetail(val gymId: String) : Screen()
|
||||
|
||||
@Serializable data class AddEditGym(val gymId: String? = null) : Screen()
|
||||
|
||||
@Serializable
|
||||
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
|
||||
|
||||
|
||||
@Serializable
|
||||
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -47,11 +47,9 @@ fun OpenClimbApp(
|
||||
val viewModel: ClimbViewModel =
|
||||
viewModel(factory = ClimbViewModelFactory(repository, syncService))
|
||||
|
||||
// Notification permission state
|
||||
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
|
||||
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
|
||||
|
||||
// Permission launcher
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
@@ -75,13 +73,11 @@ fun OpenClimbApp(
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
|
||||
|
||||
// Trigger auto-sync on app launch
|
||||
LaunchedEffect(Unit) { syncService.triggerAutoSync() }
|
||||
|
||||
val activeSession by viewModel.activeSession.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
|
||||
// Update last used gym when gyms change
|
||||
LaunchedEffect(gyms) {
|
||||
if (gyms.isNotEmpty() && lastUsedGym == null) {
|
||||
lastUsedGym = viewModel.getLastUsedGym()
|
||||
@@ -116,7 +112,6 @@ fun OpenClimbApp(
|
||||
}
|
||||
}
|
||||
|
||||
// Process shortcut actions after data is loaded
|
||||
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
|
||||
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
|
||||
android.util.Log.d(
|
||||
@@ -140,7 +135,6 @@ fun OpenClimbApp(
|
||||
)
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
// Try to get the last used gym from the intent or fallback to state
|
||||
val targetGym =
|
||||
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
|
||||
?: lastUsedGym
|
||||
@@ -167,7 +161,6 @@ fun OpenClimbApp(
|
||||
)
|
||||
}
|
||||
|
||||
// Clear the shortcut action after processing to prevent repeated execution
|
||||
onShortcutActionProcessed()
|
||||
}
|
||||
}
|
||||
@@ -215,8 +208,6 @@ fun OpenClimbApp(
|
||||
if (gyms.size == 1) {
|
||||
viewModel.startSession(context, gyms.first().id)
|
||||
} else {
|
||||
// Always show gym selection for FAB when
|
||||
// multiple gyms
|
||||
navController.navigate(Screen.AddEditSession())
|
||||
}
|
||||
}
|
||||
@@ -362,7 +353,6 @@ fun OpenClimbApp(
|
||||
}
|
||||
}
|
||||
|
||||
// Notification permission dialog
|
||||
if (showNotificationPermissionDialog) {
|
||||
NotificationPermissionDialog(
|
||||
onDismiss = { showNotificationPermissionDialog = false },
|
||||
@@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen) {
|
||||
// Clear the entire back stack and go to the selected tab's root screen
|
||||
popUpTo(0) { inclusive = true }
|
||||
// Avoid multiple copies of the same destination when
|
||||
// reselecting the same item
|
||||
launchSingleTop = true
|
||||
// Don't restore state - always start fresh when switching tabs
|
||||
restoreState = false
|
||||
|
||||
@@ -54,15 +54,15 @@ fun BarChart(
|
||||
val chartWidth = size.width - padding * 2
|
||||
val chartHeight = size.height - padding * 2
|
||||
|
||||
// Sort data by grade numeric value for proper ordering
|
||||
// Sort data by grade numeric value
|
||||
val sortedData = data.sortedBy { it.gradeNumeric }
|
||||
|
||||
// Calculate max value for scaling
|
||||
// Calculate max value
|
||||
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
|
||||
|
||||
// Calculate bar dimensions
|
||||
// Bar dimensions
|
||||
val barCount = sortedData.size
|
||||
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing
|
||||
val totalSpacing = chartWidth * 0.2f
|
||||
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
|
||||
val barWidth = (chartWidth - totalSpacing) / barCount
|
||||
|
||||
@@ -106,25 +106,25 @@ fun BarChart(
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
|
||||
)
|
||||
|
||||
// Draw value on top of bar (if there's space)
|
||||
// Draw value on bar
|
||||
if (dataPoint.value > 0) {
|
||||
val valueText = dataPoint.value.toString()
|
||||
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
|
||||
val textSize = textMeasurer.measure(valueText, textStyle)
|
||||
|
||||
// Position text on top of bar or inside if bar is tall enough
|
||||
// Position text
|
||||
val textY =
|
||||
if (barHeight > textSize.size.height + 8.dp.toPx()) {
|
||||
barY + 8.dp.toPx() // Inside bar
|
||||
barY + 8.dp.toPx()
|
||||
} else {
|
||||
barY - 4.dp.toPx() // Above bar
|
||||
barY - 4.dp.toPx()
|
||||
}
|
||||
|
||||
val textColor =
|
||||
if (barHeight > textSize.size.height + 8.dp.toPx()) {
|
||||
Color.White // White text inside bar
|
||||
Color.White
|
||||
} else {
|
||||
style.textColor // Regular color above bar
|
||||
style.textColor
|
||||
}
|
||||
|
||||
drawText(
|
||||
@@ -166,7 +166,7 @@ private fun DrawScope.drawGrid(
|
||||
) {
|
||||
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
|
||||
|
||||
// Draw horizontal grid lines (Y-axis)
|
||||
// Horizontal grid lines
|
||||
val gridLines =
|
||||
when {
|
||||
maxValue <= 5 -> (0..maxValue).toList()
|
||||
|
||||
@@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter
|
||||
|
||||
object DateFormatUtils {
|
||||
|
||||
/**
|
||||
* ISO 8601 formatter matching iOS date format exactly Produces dates like:
|
||||
* "2025-09-07T22:00:40.014Z"
|
||||
*/
|
||||
// ISO 8601 formatter matching iOS date format exactly
|
||||
private val ISO_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC)
|
||||
|
||||
/**
|
||||
* Get current timestamp in iOS-compatible ISO 8601 format
|
||||
* @return Current timestamp as "2025-09-07T22:00:40.014Z"
|
||||
*/
|
||||
/** Get current timestamp in iOS-compatible ISO 8601 format */
|
||||
fun nowISO8601(): String {
|
||||
return ISO_FORMATTER.format(Instant.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an Instant to iOS-compatible ISO 8601 format
|
||||
* @param instant The instant to format
|
||||
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
|
||||
*/
|
||||
/** Format an Instant to iOS-compatible ISO 8601 format */
|
||||
fun formatISO8601(instant: Instant): String {
|
||||
return ISO_FORMATTER.format(instant)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an iOS-compatible ISO 8601 date string back to Instant
|
||||
* @param dateString ISO 8601 formatted date string
|
||||
* @return Instant object, or null if parsing fails
|
||||
*/
|
||||
/** Parse an iOS-compatible ISO 8601 date string back to Instant */
|
||||
fun parseISO8601(dateString: String): Instant? {
|
||||
return try {
|
||||
Instant.from(ISO_FORMATTER.parse(dateString))
|
||||
} catch (e: Exception) {
|
||||
// Fallback - try standard Instant parsing
|
||||
|
||||
try {
|
||||
Instant.parse(dateString)
|
||||
} catch (e2: Exception) {
|
||||
@@ -48,20 +34,12 @@ object DateFormatUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a date string matches the expected iOS format
|
||||
* @param dateString The date string to validate
|
||||
* @return True if the format matches iOS expectations
|
||||
*/
|
||||
/** Validate that a date string matches the expected iOS format */
|
||||
fun isValidISO8601(dateString: String): Boolean {
|
||||
return parseISO8601(dateString) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert milliseconds timestamp to iOS-compatible ISO 8601 format
|
||||
* @param millis Milliseconds since epoch
|
||||
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
|
||||
*/
|
||||
/** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
|
||||
fun millisToISO8601(millis: Long): String {
|
||||
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
|
||||
}
|
||||
|
||||
@@ -12,15 +12,7 @@ object ImageNamingUtils {
|
||||
private const val IMAGE_EXTENSION = ".jpg"
|
||||
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
||||
|
||||
/**
|
||||
* Generates a deterministic filename for a problem image. Format:
|
||||
* "problem_{problemId}_{timestamp}_{index}.jpg"
|
||||
*
|
||||
* @param problemId The ID of the problem this image belongs to
|
||||
* @param timestamp ISO8601 timestamp when the image was created
|
||||
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
|
||||
* @return A consistent filename that will be the same across platforms
|
||||
*/
|
||||
/** Generates a deterministic filename for a problem image */
|
||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||
// Create a deterministic hash from problemId + timestamp + index
|
||||
val input = "${problemId}_${timestamp}_${imageIndex}"
|
||||
@@ -29,25 +21,13 @@ object ImageNamingUtils {
|
||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic filename for a problem image using current timestamp.
|
||||
*
|
||||
* @param problemId The ID of the problem this image belongs to
|
||||
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
|
||||
* @return A consistent filename
|
||||
*/
|
||||
/** Generates a deterministic filename using current timestamp */
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val timestamp = DateFormatUtils.nowISO8601()
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts problem ID from an image filename created by this utility. Returns null if the
|
||||
* filename doesn't match our naming convention.
|
||||
*
|
||||
* @param filename The image filename
|
||||
* @return The problem ID or null if not a valid filename
|
||||
*/
|
||||
/** Extracts problem ID from an image filename */
|
||||
fun extractProblemIdFromFilename(filename: String): String? {
|
||||
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
|
||||
return null
|
||||
@@ -66,12 +46,7 @@ object ImageNamingUtils {
|
||||
return parts[1] // Return the hash as identifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a filename follows our naming convention.
|
||||
*
|
||||
* @param filename The filename to validate
|
||||
* @return true if it matches our convention, false otherwise
|
||||
*/
|
||||
/** Validates if a filename follows our naming convention */
|
||||
fun isValidImageFilename(filename: String): Boolean {
|
||||
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
|
||||
return false
|
||||
@@ -86,15 +61,7 @@ object ImageNamingUtils {
|
||||
parts[2].toIntOrNull() != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates an existing UUID-based filename to our naming convention. This is used during sync
|
||||
* to rename downloaded images.
|
||||
*
|
||||
* @param oldFilename The existing filename (UUID-based)
|
||||
* @param problemId The problem ID this image belongs to
|
||||
* @param imageIndex The index of this image
|
||||
* @return The new filename following our convention
|
||||
*/
|
||||
/** Migrates an existing filename to our naming convention */
|
||||
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
|
||||
// If it's already using our convention, keep it
|
||||
if (isValidImageFilename(oldFilename)) {
|
||||
@@ -107,13 +74,7 @@ object ImageNamingUtils {
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters
|
||||
* for filename safety.
|
||||
*
|
||||
* @param input The input string to hash
|
||||
* @return First 12 characters of SHA-256 hash in lowercase
|
||||
*/
|
||||
/** Creates a deterministic hash from input string */
|
||||
private fun createHash(input: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
|
||||
@@ -121,14 +82,7 @@ object ImageNamingUtils {
|
||||
return hashHex.take(HASH_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch renames images for a problem to use our naming convention. Returns a mapping of old
|
||||
* filename -> new filename.
|
||||
*
|
||||
* @param problemId The problem ID
|
||||
* @param existingFilenames List of current image filenames for this problem
|
||||
* @return Map of old filename to new filename
|
||||
*/
|
||||
/** Batch renames images for a problem to use our naming convention */
|
||||
fun batchRenameForProblem(
|
||||
problemId: String,
|
||||
existingFilenames: List<String>
|
||||
|
||||
@@ -16,7 +16,7 @@ object ImageUtils {
|
||||
private const val MAX_IMAGE_SIZE = 1024
|
||||
private const val IMAGE_QUALITY = 85
|
||||
|
||||
/** Creates the images directory if it doesn't exist */
|
||||
// Creates the images directory if it doesn't exist
|
||||
private fun getImagesDirectory(context: Context): File {
|
||||
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||
if (!imagesDir.exists()) {
|
||||
@@ -25,14 +25,7 @@ object ImageUtils {
|
||||
return imagesDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an image from a URI with compression and proper orientation
|
||||
* @param context Android context
|
||||
* @param imageUri URI of the image to save
|
||||
* @param problemId The problem ID this image belongs to (optional)
|
||||
* @param imageIndex The index of this image for the problem (optional)
|
||||
* @return The relative file path if successful, null otherwise
|
||||
*/
|
||||
/** Saves an image from a URI with compression and proper orientation */
|
||||
fun saveImageFromUri(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
@@ -40,7 +33,7 @@ object ImageUtils {
|
||||
imageIndex: Int? = null
|
||||
): String? {
|
||||
return try {
|
||||
// Decode bitmap from a fresh stream to avoid mark/reset dependency
|
||||
|
||||
val originalBitmap =
|
||||
context.contentResolver.openInputStream(imageUri)?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
@@ -50,7 +43,6 @@ object ImageUtils {
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
// Generate filename using naming convention if problem info provided
|
||||
val filename =
|
||||
if (problemId != null && imageIndex != null) {
|
||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
@@ -59,19 +51,16 @@ object ImageUtils {
|
||||
}
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
// Clean up bitmaps
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
compressedBitmap.recycle()
|
||||
|
||||
// Return relative path
|
||||
"$IMAGES_DIR/$filename"
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -162,12 +151,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the full file path for an image
|
||||
* @param context Android context
|
||||
* @param relativePath The relative path returned by saveImageFromUri
|
||||
* @return Full file path
|
||||
*/
|
||||
/** Gets the full file path for an image */
|
||||
fun getImageFile(context: Context, relativePath: String): File {
|
||||
// If relativePath already contains the directory, use it as-is
|
||||
// Otherwise, assume it's just a filename and add the images directory
|
||||
@@ -179,12 +163,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image file
|
||||
* @param context Android context
|
||||
* @param relativePath The relative path of the image to delete
|
||||
* @return true if deleted successfully, false otherwise
|
||||
*/
|
||||
/** Deletes an image file */
|
||||
fun deleteImage(context: Context, relativePath: String): Boolean {
|
||||
return try {
|
||||
val file = getImageFile(context, relativePath)
|
||||
@@ -195,12 +174,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports an image file from the import directory
|
||||
* @param context Android context
|
||||
* @param sourceFile The source image file to import
|
||||
* @return The relative path in app storage, null if failed
|
||||
*/
|
||||
/** Imports an image file from the import directory */
|
||||
fun importImageFile(context: Context, sourceFile: File): String? {
|
||||
return try {
|
||||
if (!sourceFile.exists()) return null
|
||||
@@ -218,11 +192,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all image files in the images directory
|
||||
* @param context Android context
|
||||
* @return List of relative paths for all images
|
||||
*/
|
||||
/** Gets all image files in the images directory */
|
||||
fun getAllImages(context: Context): List<String> {
|
||||
return try {
|
||||
val imagesDir = getImagesDirectory(context)
|
||||
@@ -242,12 +212,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an image from byte array to app's private storage
|
||||
* @param context Android context
|
||||
* @param imageData Byte array of the image data
|
||||
* @return The relative file path if successful, null otherwise
|
||||
*/
|
||||
/** Saves an image from byte array to app's private storage */
|
||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
||||
return try {
|
||||
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
|
||||
@@ -275,13 +240,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves image data with a specific filename (used for sync to preserve server filenames)
|
||||
* @param context Android context
|
||||
* @param imageData The image data as byte array
|
||||
* @param filename The specific filename to use (including extension)
|
||||
* @return The relative file path if successful, null otherwise
|
||||
*/
|
||||
/** Saves image data with a specific filename */
|
||||
fun saveImageFromBytesWithFilename(
|
||||
context: Context,
|
||||
imageData: ByteArray,
|
||||
@@ -312,13 +271,7 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates existing images to use consistent naming convention
|
||||
* @param context Android context
|
||||
* @param problemId The problem ID these images belong to
|
||||
* @param currentImagePaths List of current image paths for this problem
|
||||
* @return Map of old path -> new path for successfully migrated images
|
||||
*/
|
||||
/** Migrates existing images to use consistent naming convention */
|
||||
fun migrateImageNaming(
|
||||
context: Context,
|
||||
problemId: String,
|
||||
@@ -349,12 +302,7 @@ object ImageUtils {
|
||||
return migrationMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch migrates all images in the system to use consistent naming
|
||||
* @param context Android context
|
||||
* @param problemImageMap Map of problem ID -> list of current image paths
|
||||
* @return Map of old path -> new path for all migrated images
|
||||
*/
|
||||
/** Batch migrates all images in the system to use consistent naming */
|
||||
fun batchMigrateAllImages(
|
||||
context: Context,
|
||||
problemImageMap: Map<String, List<String>>
|
||||
@@ -369,11 +317,7 @@ object ImageUtils {
|
||||
return allMigrations
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up orphaned images that are not referenced by any problems
|
||||
* @param context Android context
|
||||
* @param referencedPaths Set of image paths that are still being used
|
||||
*/
|
||||
/** Cleans up orphaned images that are not referenced by any problems */
|
||||
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
|
||||
try {
|
||||
val allImages = getAllImages(context)
|
||||
|
||||
@@ -20,14 +20,7 @@ object ZipExportImportUtils {
|
||||
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
|
||||
* @param exportData The data to export (should be serializable)
|
||||
* @param referencedImagePaths Set of image paths referenced in the data
|
||||
* @param directory Optional directory to save to, uses default if null
|
||||
* @return The created ZIP file
|
||||
*/
|
||||
/** Creates a ZIP file containing the JSON data and all referenced images */
|
||||
fun createExportZip(
|
||||
context: Context,
|
||||
exportData: ClimbDataBackup,
|
||||
@@ -120,13 +113,7 @@ object ZipExportImportUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ZIP file and writes it to a provided URI
|
||||
* @param context Android context
|
||||
* @param uri The URI to write to
|
||||
* @param exportData The data to export
|
||||
* @param referencedImagePaths Set of image paths referenced in the data
|
||||
*/
|
||||
/** Creates a ZIP file and writes it to a provided URI */
|
||||
fun createExportZipToUri(
|
||||
context: Context,
|
||||
uri: android.net.Uri,
|
||||
@@ -214,12 +201,7 @@ object ZipExportImportUtils {
|
||||
val importedImagePaths: Map<String, String> // original filename -> new relative path
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts a ZIP file and returns the JSON content and imported image paths
|
||||
* @param context Android context
|
||||
* @param zipFile The ZIP file to extract
|
||||
* @return ImportResult containing the JSON and image path mappings
|
||||
*/
|
||||
/** Extracts a ZIP file and returns the JSON content and imported image paths */
|
||||
fun extractImportZip(context: Context, zipFile: File): ImportResult {
|
||||
var jsonContent = ""
|
||||
val importedImagePaths = mutableMapOf<String, String>()
|
||||
|
||||
Reference in New Issue
Block a user