iOS Build 23
This commit is contained in:
@@ -16,7 +16,7 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 35
|
||||
versionCode = 36
|
||||
versionName = "1.8.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
// Room Database
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -54,9 +56,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
private val httpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.connectTimeout(45, TimeUnit.SECONDS)
|
||||
.readTimeout(90, TimeUnit.SECONDS)
|
||||
.writeTimeout(90, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val json = Json {
|
||||
@@ -86,6 +88,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
private val _isTesting = MutableStateFlow(false)
|
||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
||||
|
||||
// Debounced sync properties
|
||||
private var syncJob: Job? = null
|
||||
private var pendingChanges = false
|
||||
private val syncDebounceDelay = 2000L // 2 seconds
|
||||
|
||||
// Configuration keys
|
||||
private object Keys {
|
||||
const val SERVER_URL = "sync_server_url"
|
||||
@@ -137,6 +144,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
repository.setAutoSyncCallback {
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
|
||||
}
|
||||
|
||||
// Perform image naming migration on initialization
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { performImageNamingMigration() }
|
||||
}
|
||||
|
||||
suspend fun downloadData(): ClimbDataBackup =
|
||||
@@ -297,6 +307,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
try {
|
||||
val response = httpClient.newCall(request).execute()
|
||||
Log.d(TAG, "Image download response for $filename: ${response.code}")
|
||||
if (response.code != 200) {
|
||||
Log.w(TAG, "Failed request URL: ${request.url}")
|
||||
}
|
||||
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
@@ -426,28 +439,23 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
totalImages += imageCount
|
||||
}
|
||||
|
||||
problem.imagePaths?.forEachIndexed { index, imagePath ->
|
||||
problem.imagePaths?.forEach { imagePath ->
|
||||
try {
|
||||
Log.d(TAG, "Attempting to download image: $imagePath")
|
||||
val imageData = downloadImage(imagePath)
|
||||
|
||||
// Use the server's actual filename, not regenerated
|
||||
val serverFilename = imagePath.substringAfterLast('/')
|
||||
val consistentFilename =
|
||||
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
|
||||
serverFilename
|
||||
} else {
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Attempting to download image: $serverFilename")
|
||||
val imageData = downloadImage(serverFilename)
|
||||
|
||||
val localImagePath =
|
||||
ImageUtils.saveImageFromBytesWithFilename(
|
||||
context,
|
||||
imageData,
|
||||
consistentFilename
|
||||
serverFilename
|
||||
)
|
||||
|
||||
if (localImagePath != null) {
|
||||
imagePathMapping[serverFilename] = localImagePath
|
||||
imagePathMapping[imagePath] = localImagePath
|
||||
downloadedImages++
|
||||
Log.d(
|
||||
TAG,
|
||||
@@ -457,6 +465,12 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
Log.w(TAG, "Failed to save downloaded image locally: $imagePath")
|
||||
failedImages++
|
||||
}
|
||||
} catch (e: SyncException.ImageNotFound) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Image not found on server: $imagePath - might be missing or use different naming"
|
||||
)
|
||||
failedImages++
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to download image $imagePath: ${e.message}")
|
||||
failedImages++
|
||||
@@ -495,32 +509,24 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
if (imageFile.exists() && imageFile.length() > 0) {
|
||||
val imageData = imageFile.readBytes()
|
||||
// Always use consistent problem-based naming for uploads
|
||||
val consistentFilename =
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
|
||||
val filename = imagePath.substringAfterLast('/')
|
||||
|
||||
val consistentFilename =
|
||||
if (ImageNamingUtils.isValidImageFilename(filename)) {
|
||||
filename
|
||||
} else {
|
||||
val newFilename =
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problem.id,
|
||||
index
|
||||
)
|
||||
val newFile = java.io.File(imageFile.parent, newFilename)
|
||||
if (imageFile.renameTo(newFile)) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Renamed local image file: $filename -> $newFilename"
|
||||
)
|
||||
newFilename
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to rename local image file, using original"
|
||||
)
|
||||
filename
|
||||
}
|
||||
}
|
||||
// Rename local file if needed
|
||||
if (filename != consistentFilename) {
|
||||
val newFile = java.io.File(imageFile.parent, consistentFilename)
|
||||
if (imageFile.renameTo(newFile)) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Renamed local image file: $filename -> $consistentFilename"
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to rename local image file, using original")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Uploading image: $consistentFilename (${imageData.size} bytes)")
|
||||
uploadImage(consistentFilename, imageData)
|
||||
@@ -568,7 +574,21 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||
problems =
|
||||
allProblems.map { problem ->
|
||||
// Normalize image paths to consistent naming in backup
|
||||
val normalizedImagePaths =
|
||||
problem.imagePaths?.mapIndexed { index, _ ->
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
}
|
||||
|
||||
val backupProblem = BackupProblem.fromProblem(problem)
|
||||
if (!normalizedImagePaths.isNullOrEmpty()) {
|
||||
backupProblem.copy(imagePaths = normalizedImagePaths)
|
||||
} else {
|
||||
backupProblem
|
||||
}
|
||||
},
|
||||
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
|
||||
deletedItems = repository.getDeletedItems()
|
||||
@@ -851,10 +871,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val localIds = local.map { it.id }.toSet()
|
||||
val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
merged.removeAll { deletedGymIds.contains(it.id) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
server.forEach { serverGym ->
|
||||
if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) {
|
||||
try {
|
||||
@@ -878,24 +896,26 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val localIds = local.map { it.id }.toSet()
|
||||
val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
merged.removeAll { deletedProblemIds.contains(it.id) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
server.forEach { serverProblem ->
|
||||
if (!localIds.contains(serverProblem.id) &&
|
||||
!deletedProblemIds.contains(serverProblem.id)
|
||||
) {
|
||||
try {
|
||||
val problemToAdd =
|
||||
if (imagePathMapping.isNotEmpty()) {
|
||||
val newImagePaths =
|
||||
serverProblem.imagePaths?.map { oldPath ->
|
||||
val filename = oldPath.substringAfterLast('/')
|
||||
imagePathMapping[filename] ?: oldPath
|
||||
if (imagePathMapping.isNotEmpty() &&
|
||||
!serverProblem.imagePaths.isNullOrEmpty()
|
||||
) {
|
||||
val updatedImagePaths =
|
||||
serverProblem.imagePaths?.mapNotNull { oldPath ->
|
||||
imagePathMapping[oldPath] ?: oldPath
|
||||
}
|
||||
?: emptyList()
|
||||
serverProblem.withUpdatedImagePaths(newImagePaths)
|
||||
if (updatedImagePaths != serverProblem.imagePaths) {
|
||||
serverProblem.copy(imagePaths = updatedImagePaths)
|
||||
} else {
|
||||
serverProblem
|
||||
}
|
||||
} else {
|
||||
serverProblem
|
||||
}
|
||||
@@ -918,10 +938,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val localIds = local.map { it.id }.toSet()
|
||||
val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet()
|
||||
|
||||
// Remove items that were deleted on other devices (but never remove active sessions)
|
||||
merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
server.forEach { serverSession ->
|
||||
if (!localIds.contains(serverSession.id) &&
|
||||
!deletedSessionIds.contains(serverSession.id)
|
||||
@@ -946,10 +964,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val localIds = local.map { it.id }.toSet()
|
||||
val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet()
|
||||
|
||||
// Remove items that were deleted on other devices (but be conservative with attempts)
|
||||
merged.removeAll { deletedAttemptIds.contains(it.id) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
server.forEach { serverAttempt ->
|
||||
if (!localIds.contains(serverAttempt.id) &&
|
||||
!deletedAttemptIds.contains(serverAttempt.id)
|
||||
@@ -1093,19 +1109,54 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
|
||||
if (_isSyncing.value) {
|
||||
Log.d(TAG, "Sync already in progress, skipping auto-sync")
|
||||
pendingChanges = true
|
||||
return
|
||||
}
|
||||
|
||||
syncJob?.cancel()
|
||||
|
||||
syncJob =
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(syncDebounceDelay)
|
||||
|
||||
do {
|
||||
pendingChanges = false
|
||||
|
||||
try {
|
||||
syncWithServer()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Auto-sync failed: ${e.message}")
|
||||
_syncError.value = e.message
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (pendingChanges) {
|
||||
delay(syncDebounceDelay)
|
||||
}
|
||||
} while (pendingChanges)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun forceSyncNow() {
|
||||
if (!isConfigured || !_isConnected.value) return
|
||||
|
||||
syncJob?.cancel()
|
||||
syncJob = null
|
||||
pendingChanges = false
|
||||
|
||||
try {
|
||||
syncWithServer()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Auto-sync failed: ${e.message}")
|
||||
Log.e(TAG, "Force sync failed: ${e.message}")
|
||||
_syncError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
fun clearConfiguration() {
|
||||
syncJob?.cancel()
|
||||
syncJob = null
|
||||
pendingChanges = false
|
||||
|
||||
serverURL = ""
|
||||
authToken = ""
|
||||
isAutoSyncEnabled = true
|
||||
@@ -1116,6 +1167,113 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
sharedPreferences.edit().clear().apply()
|
||||
updateConfiguredState()
|
||||
}
|
||||
|
||||
// MARK: - Image Naming Migration
|
||||
|
||||
private suspend fun performImageNamingMigration() =
|
||||
withContext(Dispatchers.IO) {
|
||||
val migrationKey = "image_naming_migration_completed"
|
||||
if (sharedPreferences.getBoolean(migrationKey, false)) {
|
||||
Log.d(TAG, "Image naming migration already completed")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
Log.d(TAG, "Starting image naming migration...")
|
||||
var updateCount = 0
|
||||
|
||||
try {
|
||||
// Get all problems with images
|
||||
val problems = repository.getAllProblems().first()
|
||||
val updatedProblems = mutableListOf<Problem>()
|
||||
|
||||
for (problem in problems) {
|
||||
if (problem.imagePaths.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val updatedImagePaths = mutableListOf<String>()
|
||||
var hasChanges = false
|
||||
|
||||
problem.imagePaths.forEachIndexed { index, imagePath ->
|
||||
val currentFilename = imagePath.substringAfterLast('/')
|
||||
val consistentFilename =
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
|
||||
if (currentFilename != consistentFilename) {
|
||||
// Get the image file
|
||||
val oldFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
if (oldFile.exists()) {
|
||||
val newPath = "problem_images/$consistentFilename"
|
||||
val newFile = ImageUtils.getImageFile(context, newPath)
|
||||
|
||||
try {
|
||||
// Create parent directory if needed
|
||||
newFile.parentFile?.mkdirs()
|
||||
|
||||
if (oldFile.renameTo(newFile)) {
|
||||
updatedImagePaths.add(newPath)
|
||||
hasChanges = true
|
||||
updateCount++
|
||||
Log.d(
|
||||
TAG,
|
||||
"Migrated image: $currentFilename -> $consistentFilename"
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to migrate image $currentFilename")
|
||||
updatedImagePaths.add(
|
||||
imagePath
|
||||
) // Keep original on failure
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to migrate image $currentFilename: ${e.message}"
|
||||
)
|
||||
updatedImagePaths.add(imagePath) // Keep original on failure
|
||||
}
|
||||
} else {
|
||||
updatedImagePaths.add(
|
||||
imagePath
|
||||
) // Keep original if file doesn't exist
|
||||
}
|
||||
} else {
|
||||
updatedImagePaths.add(imagePath) // Already consistent
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
val updatedProblem =
|
||||
problem.copy(
|
||||
imagePaths = updatedImagePaths,
|
||||
updatedAt = DateFormatUtils.formatISO8601(Instant.now())
|
||||
)
|
||||
updatedProblems.add(updatedProblem)
|
||||
}
|
||||
}
|
||||
|
||||
// Update problems in database
|
||||
if (updatedProblems.isNotEmpty()) {
|
||||
updatedProblems.forEach { problem -> repository.updateProblem(problem) }
|
||||
Log.d(
|
||||
TAG,
|
||||
"Updated ${updatedProblems.size} problems with migrated image paths"
|
||||
)
|
||||
}
|
||||
|
||||
// Mark migration as completed
|
||||
sharedPreferences.edit().putBoolean(migrationKey, true).apply()
|
||||
Log.d(TAG, "Image naming migration completed, updated $updateCount images")
|
||||
|
||||
// Trigger sync after migration if images were updated
|
||||
if (updateCount > 0) {
|
||||
Log.d(TAG, "Triggering sync after image migration")
|
||||
triggerAutoSync()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Image naming migration failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SyncException(message: String) : Exception(message) {
|
||||
|
||||
@@ -56,7 +56,7 @@ fun ImagePicker(
|
||||
// Process images
|
||||
val newImagePaths = mutableListOf<String>()
|
||||
urisToProcess.forEach { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
newImagePaths.add(imagePath)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ fun ImagePicker(
|
||||
success ->
|
||||
if (success) {
|
||||
cameraImageUri?.let { uri ->
|
||||
val imagePath = ImageUtils.saveImageFromUri(context, uri)
|
||||
val imagePath = ImageUtils.saveTemporaryImageFromUri(context, uri)
|
||||
if (imagePath != null) {
|
||||
val updatedUris = tempImageUris + imagePath
|
||||
tempImageUris = updatedUris
|
||||
|
||||
@@ -248,6 +248,7 @@ fun AddEditProblemScreen(
|
||||
) {
|
||||
val isEditing = problemId != null
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Problem form state
|
||||
var selectedGym by remember {
|
||||
@@ -387,10 +388,11 @@ fun AddEditProblemScreen(
|
||||
|
||||
if (isEditing) {
|
||||
viewModel.updateProblem(
|
||||
problem.copy(id = problemId!!)
|
||||
problem.copy(id = problemId),
|
||||
context
|
||||
)
|
||||
} else {
|
||||
viewModel.addProblem(problem)
|
||||
viewModel.addProblem(problem, context)
|
||||
}
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ fun SessionDetailScreen(
|
||||
viewModel.addAttempt(attempt)
|
||||
showAddAttemptDialog = false
|
||||
},
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem) }
|
||||
onProblemCreated = { problem -> viewModel.addProblem(problem, context) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,6 +26,7 @@ import com.atridad.openclimb.ui.viewmodel.ClimbViewModel
|
||||
fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String) -> Unit) {
|
||||
val problems by viewModel.problems.collectAsState()
|
||||
val gyms by viewModel.gyms.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var selectedImagePaths by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var selectedImageIndex by remember { mutableIntStateOf(0) }
|
||||
@@ -184,7 +186,7 @@ fun ProblemsScreen(viewModel: ClimbViewModel, onNavigateToProblemDetail: (String
|
||||
},
|
||||
onToggleActive = {
|
||||
val updatedProblem = problem.copy(isActive = !problem.isActive)
|
||||
viewModel.updateProblem(updatedProblem)
|
||||
viewModel.updateProblem(updatedProblem, context)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -42,6 +42,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var showSyncConfigDialog by remember { mutableStateOf(false) }
|
||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||
var showFixImagesDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteImagesDialog by remember { mutableStateOf(false) }
|
||||
var isFixingImages by remember { mutableStateOf(false) }
|
||||
var isDeletingImages by remember { mutableStateOf(false) }
|
||||
|
||||
// Sync configuration state
|
||||
var serverUrl by remember { mutableStateOf(syncService.serverURL) }
|
||||
@@ -475,6 +479,88 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Fix Image Names") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Rename all images to use consistent naming across devices"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Build, contentDescription = null)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showFixImagesDialog = true },
|
||||
enabled = !isFixingImages && !uiState.isLoading
|
||||
) {
|
||||
if (isFixingImages) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Fix Names")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.errorContainer.copy(
|
||||
alpha = 0.3f
|
||||
)
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Delete All Images") },
|
||||
supportingContent = {
|
||||
Text("Permanently delete all image files from device")
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
onClick = { showDeleteImagesDialog = true },
|
||||
enabled = !isDeletingImages && !uiState.isLoading
|
||||
) {
|
||||
if (isDeletingImages) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
@@ -903,16 +989,72 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
syncService.clearConfiguration()
|
||||
serverUrl = ""
|
||||
authToken = ""
|
||||
viewModel.syncService.clearConfiguration()
|
||||
showDisconnectDialog = false
|
||||
}
|
||||
) { Text("Disconnect", color = MaterialTheme.colorScheme.error) }
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Fix Image Names dialog
|
||||
if (showFixImagesDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showFixImagesDialog = false },
|
||||
title = { Text("Fix Image Names") },
|
||||
text = {
|
||||
Text(
|
||||
"This will rename all existing image files to use a consistent naming system across devices.\n\nThis improves sync reliability between iOS and Android. Your images will not be lost, only renamed.\n\nThis is safe to run multiple times."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
isFixingImages = true
|
||||
showFixImagesDialog = false
|
||||
coroutineScope.launch {
|
||||
viewModel.migrateImageNamesToDeterministic(context)
|
||||
isFixingImages = false
|
||||
viewModel.setMessage("Image names fixed successfully!")
|
||||
}
|
||||
}
|
||||
) { Text("Fix Names") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showFixImagesDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete All Images dialog
|
||||
if (showDeleteImagesDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteImagesDialog = false },
|
||||
title = { Text("Delete All Images") },
|
||||
text = {
|
||||
Text(
|
||||
"This will permanently delete ALL image files from your device.\n\nProblems will keep their references but the actual image files will be removed. This cannot be undone.\n\nConsider exporting your data first if you want to keep your images."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
isDeletingImages = true
|
||||
showDeleteImagesDialog = false
|
||||
coroutineScope.launch {
|
||||
viewModel.deleteAllImages(context)
|
||||
isDeletingImages = false
|
||||
viewModel.setMessage("All images deleted successfully!")
|
||||
}
|
||||
}
|
||||
) { Text("Delete", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteImagesDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||
import com.atridad.openclimb.data.sync.SyncService
|
||||
import com.atridad.openclimb.service.SessionTrackingService
|
||||
import com.atridad.openclimb.utils.ImageNamingUtils
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import com.atridad.openclimb.utils.SessionShareUtils
|
||||
import com.atridad.openclimb.widget.ClimbStatsWidgetProvider
|
||||
@@ -106,25 +107,41 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
fun getGymById(id: String): Flow<Gym?> = flow { emit(repository.getGymById(id)) }
|
||||
|
||||
// Problem operations
|
||||
fun addProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.insertProblem(problem) }
|
||||
}
|
||||
|
||||
fun addProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.insertProblem(problem)
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
repository.insertProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem) {
|
||||
viewModelScope.launch { repository.updateProblem(problem) }
|
||||
private suspend fun renameTemporaryImages(problem: Problem, context: Context? = null): Problem {
|
||||
if (problem.imagePaths.isEmpty()) {
|
||||
return problem
|
||||
}
|
||||
|
||||
val appContext = context ?: return problem
|
||||
val finalImagePaths = mutableListOf<String>()
|
||||
|
||||
problem.imagePaths.forEachIndexed { index, tempPath ->
|
||||
if (tempPath.startsWith("temp_")) {
|
||||
val deterministicName = ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
val finalPath =
|
||||
ImageUtils.renameTemporaryImage(appContext, tempPath, problem.id, index)
|
||||
finalImagePaths.add(finalPath ?: tempPath)
|
||||
} else {
|
||||
finalImagePaths.add(tempPath)
|
||||
}
|
||||
}
|
||||
|
||||
return problem.copy(imagePaths = finalImagePaths)
|
||||
}
|
||||
|
||||
fun updateProblem(problem: Problem, context: Context) {
|
||||
viewModelScope.launch {
|
||||
repository.updateProblem(problem)
|
||||
val finalProblem = renameTemporaryImages(problem, context)
|
||||
repository.updateProblem(finalProblem)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
}
|
||||
}
|
||||
@@ -147,6 +164,99 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
ImageUtils.cleanupOrphanedImages(context, referencedImagePaths)
|
||||
}
|
||||
fun migrateImageNamesToDeterministic(context: Context) {
|
||||
viewModelScope.launch {
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
var migrationCount = 0
|
||||
val updatedProblems = mutableListOf<Problem>()
|
||||
|
||||
for (problem in allProblems) {
|
||||
if (problem.imagePaths.isEmpty()) continue
|
||||
|
||||
var newImagePaths = mutableListOf<String>()
|
||||
var problemNeedsUpdate = false
|
||||
|
||||
for ((index, imagePath) in problem.imagePaths.withIndex()) {
|
||||
val currentFilename = File(imagePath).name
|
||||
|
||||
if (ImageNamingUtils.isValidImageFilename(currentFilename)) {
|
||||
newImagePaths.add(imagePath)
|
||||
continue
|
||||
}
|
||||
|
||||
val deterministicName =
|
||||
ImageNamingUtils.generateImageFilename(problem.id, index)
|
||||
|
||||
val imagesDir = ImageUtils.getImagesDirectory(context)
|
||||
val oldFile = File(imagesDir, currentFilename)
|
||||
val newFile = File(imagesDir, deterministicName)
|
||||
|
||||
if (oldFile.exists()) {
|
||||
if (oldFile.renameTo(newFile)) {
|
||||
newImagePaths.add(deterministicName)
|
||||
problemNeedsUpdate = true
|
||||
migrationCount++
|
||||
println("Migrated: $currentFilename → $deterministicName")
|
||||
} else {
|
||||
println("Failed to migrate $currentFilename")
|
||||
newImagePaths.add(imagePath)
|
||||
}
|
||||
} else {
|
||||
println("Warning: Image file not found: $currentFilename")
|
||||
newImagePaths.add(imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
if (problemNeedsUpdate) {
|
||||
val updatedProblem = problem.copy(imagePaths = newImagePaths)
|
||||
updatedProblems.add(updatedProblem)
|
||||
}
|
||||
}
|
||||
|
||||
for (updatedProblem in updatedProblems) {
|
||||
repository.insertProblemWithoutSync(updatedProblem)
|
||||
}
|
||||
|
||||
println(
|
||||
"Migration completed: $migrationCount images renamed, ${updatedProblems.size} problems updated"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllImages(context: Context) {
|
||||
viewModelScope.launch {
|
||||
val imagesDir = ImageUtils.getImagesDirectory(context)
|
||||
var deletedCount = 0
|
||||
|
||||
imagesDir.listFiles()?.forEach { file ->
|
||||
if (file.isFile && file.extension.lowercase() == "jpg") {
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allProblems = repository.getAllProblems().first()
|
||||
val updatedProblems =
|
||||
allProblems.map { problem ->
|
||||
if (problem.imagePaths.isNotEmpty()) {
|
||||
problem.copy(imagePaths = emptyList())
|
||||
} else {
|
||||
problem
|
||||
}
|
||||
}
|
||||
|
||||
for (updatedProblem in updatedProblems) {
|
||||
if (updatedProblem.imagePaths !=
|
||||
allProblems.find { it.id == updatedProblem.id }?.imagePaths
|
||||
) {
|
||||
repository.insertProblemWithoutSync(updatedProblem)
|
||||
}
|
||||
}
|
||||
|
||||
println("Deleted $deletedCount image files and cleared image references")
|
||||
}
|
||||
}
|
||||
|
||||
fun getProblemById(id: String): Flow<Problem?> = flow { emit(repository.getProblemById(id)) }
|
||||
|
||||
@@ -240,7 +350,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
android.util.Log.d("ClimbViewModel", "Session started successfully")
|
||||
_uiState.value = _uiState.value.copy(message = "Session started successfully!")
|
||||
}
|
||||
}
|
||||
@@ -268,8 +377,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
|
||||
_uiState.value = _uiState.value.copy(message = "Session completed!")
|
||||
}
|
||||
}
|
||||
@@ -295,7 +402,6 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
viewModelScope.launch {
|
||||
repository.insertAttempt(attempt)
|
||||
ClimbStatsWidgetProvider.updateAllWidgets(context)
|
||||
// Auto-sync now happens automatically via repository callback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +516,10 @@ class ClimbViewModel(private val repository: ClimbRepository, val syncService: S
|
||||
_uiState.value = _uiState.value.copy(error = message)
|
||||
}
|
||||
|
||||
fun setMessage(message: String) {
|
||||
_uiState.value = _uiState.value.copy(message = message)
|
||||
}
|
||||
|
||||
fun resetAllData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
||||
@@ -13,18 +13,16 @@ object ImageNamingUtils {
|
||||
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
|
||||
|
||||
/** 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}"
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val input = "${problemId}_${imageIndex}"
|
||||
val hash = createHash(input)
|
||||
|
||||
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
|
||||
}
|
||||
|
||||
/** Generates a deterministic filename using current timestamp */
|
||||
fun generateImageFilename(problemId: String, imageIndex: Int): String {
|
||||
val timestamp = DateFormatUtils.nowISO8601()
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
/** Legacy method for backward compatibility */
|
||||
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Extracts problem ID from an image filename */
|
||||
@@ -41,9 +39,7 @@ object ImageNamingUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
// We can't extract the original problem ID from the hash,
|
||||
// but we can validate the format
|
||||
return parts[1] // Return the hash as identifier
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
/** Validates if a filename follows our naming convention */
|
||||
@@ -63,15 +59,11 @@ object ImageNamingUtils {
|
||||
|
||||
/** 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)) {
|
||||
return oldFilename
|
||||
}
|
||||
|
||||
// Generate new deterministic name
|
||||
// Use a timestamp based on the old filename to maintain some consistency
|
||||
val timestamp = DateFormatUtils.nowISO8601()
|
||||
return generateImageFilename(problemId, timestamp, imageIndex)
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Creates a deterministic hash from input string */
|
||||
@@ -90,7 +82,7 @@ object ImageNamingUtils {
|
||||
val renameMap = mutableMapOf<String, String>()
|
||||
|
||||
existingFilenames.forEachIndexed { index, oldFilename ->
|
||||
val newFilename = migrateFilename(oldFilename, problemId, index)
|
||||
val newFilename = generateImageFilename(problemId, index)
|
||||
if (newFilename != oldFilename) {
|
||||
renameMap[oldFilename] = newFilename
|
||||
}
|
||||
@@ -98,4 +90,37 @@ object ImageNamingUtils {
|
||||
|
||||
return renameMap
|
||||
}
|
||||
|
||||
/** Generates the canonical filename for a problem image */
|
||||
fun getCanonicalImageFilename(problemId: String, imageIndex: Int): String {
|
||||
return generateImageFilename(problemId, imageIndex)
|
||||
}
|
||||
|
||||
/** Creates a mapping of existing server filenames to canonical filenames */
|
||||
fun createServerMigrationMap(
|
||||
problemId: String,
|
||||
serverImageFilenames: List<String>,
|
||||
localImageCount: Int
|
||||
): Map<String, String> {
|
||||
val migrationMap = mutableMapOf<String, String>()
|
||||
|
||||
for (imageIndex in 0 until localImageCount) {
|
||||
val canonicalName = getCanonicalImageFilename(problemId, imageIndex)
|
||||
|
||||
if (serverImageFilenames.contains(canonicalName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (serverFilename in serverImageFilenames) {
|
||||
if (isValidImageFilename(serverFilename) &&
|
||||
!migrationMap.values.contains(serverFilename)
|
||||
) {
|
||||
migrationMap[serverFilename] = canonicalName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrationMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.graphics.scale
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -17,7 +19,7 @@ object ImageUtils {
|
||||
private const val IMAGE_QUALITY = 85
|
||||
|
||||
// Creates the images directory if it doesn't exist
|
||||
private fun getImagesDirectory(context: Context): File {
|
||||
fun getImagesDirectory(context: Context): File {
|
||||
val imagesDir = File(context.filesDir, IMAGES_DIR)
|
||||
if (!imagesDir.exists()) {
|
||||
imagesDir.mkdirs()
|
||||
@@ -43,12 +45,12 @@ object ImageUtils {
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
val filename =
|
||||
if (problemId != null && imageIndex != null) {
|
||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
} else {
|
||||
"${UUID.randomUUID()}.jpg"
|
||||
}
|
||||
// Always require deterministic naming - no UUID fallback
|
||||
require(problemId != null && imageIndex != null) {
|
||||
"Problem ID and image index are required for deterministic image naming"
|
||||
}
|
||||
|
||||
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
@@ -73,35 +75,35 @@ object ImageUtils {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(imageUri)
|
||||
inputStream?.use { input ->
|
||||
val exif = android.media.ExifInterface(input)
|
||||
val exif = androidx.exifinterface.media.ExifInterface(input)
|
||||
val orientation =
|
||||
exif.getAttributeInt(
|
||||
android.media.ExifInterface.TAG_ORIENTATION,
|
||||
android.media.ExifInterface.ORIENTATION_NORMAL
|
||||
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
|
||||
val matrix = android.graphics.Matrix()
|
||||
when (orientation) {
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
||||
matrix.postRotate(90f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
||||
matrix.postRotate(180f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
||||
matrix.postRotate(270f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
matrix.postScale(1f, -1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(-90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
@@ -212,6 +214,72 @@ object ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily saves an image during selection process */
|
||||
fun saveTemporaryImageFromUri(context: Context, imageUri: Uri): String? {
|
||||
return try {
|
||||
val originalBitmap =
|
||||
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
|
||||
?: return null
|
||||
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
val tempFilename = "temp_${UUID.randomUUID()}.jpg"
|
||||
val imageFile = File(getImagesDirectory(context), tempFilename)
|
||||
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
if (compressedBitmap != orientedBitmap) {
|
||||
compressedBitmap.recycle()
|
||||
}
|
||||
|
||||
tempFilename
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageUtils", "Error saving temporary image from URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames a temporary image */
|
||||
fun renameTemporaryImage(
|
||||
context: Context,
|
||||
tempFilename: String,
|
||||
problemId: String,
|
||||
imageIndex: Int
|
||||
): String? {
|
||||
return try {
|
||||
val tempFile = File(getImagesDirectory(context), tempFilename)
|
||||
if (!tempFile.exists()) {
|
||||
Log.e("ImageUtils", "Temporary file does not exist: $tempFilename")
|
||||
return null
|
||||
}
|
||||
|
||||
val deterministicFilename =
|
||||
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
val finalFile = File(getImagesDirectory(context), deterministicFilename)
|
||||
|
||||
if (tempFile.renameTo(finalFile)) {
|
||||
Log.d(
|
||||
"ImageUtils",
|
||||
"Renamed temporary image: $tempFilename -> $deterministicFilename"
|
||||
)
|
||||
deterministicFilename
|
||||
} else {
|
||||
Log.e("ImageUtils", "Failed to rename temporary image: $tempFilename")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageUtils", "Error renaming temporary image", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves an image from byte array to app's private storage */
|
||||
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
|
||||
return try {
|
||||
|
||||
@@ -19,6 +19,7 @@ kotlinxSerialization = "1.9.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
coil = "2.7.0"
|
||||
ksp = "2.2.20-2.0.3"
|
||||
exifinterface = "1.3.6"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -66,6 +67,7 @@ mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
|
||||
|
||||
# Image Loading
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user