[Android] 1.9.1 - EXIF Fixes

This commit is contained in:
2025-10-12 01:46:16 -06:00
parent 77f8110d85
commit 405fb06d5d
15 changed files with 527 additions and 542 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId = "com.atridad.openclimb" applicationId = "com.atridad.openclimb"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 37 versionCode = 38
versionName = "1.9.0" versionName = "1.9.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -88,6 +88,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
private val _isTesting = MutableStateFlow(false) private val _isTesting = MutableStateFlow(false)
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow() val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
private val _isAutoSyncEnabled = MutableStateFlow(true)
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
// Debounced sync properties // Debounced sync properties
private var syncJob: Job? = null private var syncJob: Job? = null
private var pendingChanges = false private var pendingChanges = false
@@ -130,15 +133,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty() _isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
} }
var isAutoSyncEnabled: Boolean fun setAutoSyncEnabled(enabled: Boolean) {
get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true) sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, enabled).apply()
set(value) { _isAutoSyncEnabled.value = enabled
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply() }
}
init { init {
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false) _isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null) _lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
updateConfiguredState() updateConfiguredState()
repository.setAutoSyncCallback { repository.setAutoSyncCallback {
@@ -1104,7 +1107,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
} }
suspend fun triggerAutoSync() { suspend fun triggerAutoSync() {
if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) { if (!isConfigured || !_isConnected.value || !_isAutoSyncEnabled.value) {
return return
} }
@@ -1159,7 +1162,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
serverURL = "" serverURL = ""
authToken = "" authToken = ""
isAutoSyncEnabled = true setAutoSyncEnabled(true)
_lastSyncTime.value = null _lastSyncTime.value = null
_isConnected.value = false _isConnected.value = false
_syncError.value = null _syncError.value = null

View File

@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -20,95 +19,67 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun FullscreenImageViewer( fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
imagePaths: List<String>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val pagerState = rememberPagerState( val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
initialPage = initialIndex,
pageCount = { imagePaths.size }
)
val thumbnailListState = rememberLazyListState() val thumbnailListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Auto-scroll thumbnail list to center current image // Auto-scroll thumbnail list to center current image
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
thumbnailListState.animateScrollToItem( thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
index = pagerState.currentPage,
scrollOffset = -200
)
} }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties( properties =
usePlatformDefaultWidth = false, DialogProperties(
decorFitsSystemWindows = false usePlatformDefaultWidth = false,
) decorFitsSystemWindows = false
)
) { ) {
Box( Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main image pager // Main image pager
HorizontalPager( HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
state = pagerState, OrientationAwareImage(
modifier = Modifier.fillMaxSize() imagePath = imagePaths[page],
) { page -> contentDescription = "Full screen image",
ZoomableImage( modifier = Modifier.fillMaxSize(),
imagePath = imagePaths[page], contentScale = ContentScale.Fit
modifier = Modifier.fillMaxSize()
) )
} }
// Close button // Close button
IconButton( IconButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier =
.align(Alignment.TopEnd) Modifier.align(Alignment.TopEnd)
.padding(16.dp) .padding(16.dp)
.background( .background(Color.Black.copy(alpha = 0.5f), CircleShape)
Color.Black.copy(alpha = 0.5f), ) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
CircleShape
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White
)
}
// Image counter // Image counter
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card( Card(
modifier = Modifier modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
.align(Alignment.TopCenter) colors =
.padding(16.dp), CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = Color.Black.copy(alpha = 0.7f)
containerColor = Color.Black.copy(alpha = 0.7f) )
)
) { ) {
Text( Text(
text = "${pagerState.currentPage + 1} / ${imagePaths.size}", text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
color = Color.White, color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
) )
} }
} }
@@ -116,44 +87,46 @@ fun FullscreenImageViewer(
// Thumbnail strip (if multiple images) // Thumbnail strip (if multiple images)
if (imagePaths.size > 1) { if (imagePaths.size > 1) {
Card( Card(
modifier = Modifier modifier =
.align(Alignment.BottomCenter) Modifier.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
colors = CardDefaults.cardColors( colors =
containerColor = Color.Black.copy(alpha = 0.7f) CardDefaults.cardColors(
) containerColor = Color.Black.copy(alpha = 0.7f)
)
) { ) {
LazyRow( LazyRow(
state = thumbnailListState, state = thumbnailListState,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp) contentPadding = PaddingValues(horizontal = 8.dp)
) { ) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath)
val isSelected = index == pagerState.currentPage val isSelected = index == pagerState.currentPage
AsyncImage( OrientationAwareImage(
model = imageFile, imagePath = imagePath,
contentDescription = "Thumbnail ${index + 1}", contentDescription = "Thumbnail ${index + 1}",
modifier = Modifier modifier =
.size(60.dp) Modifier.size(60.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { .clickable {
coroutineScope.launch { coroutineScope.launch {
pagerState.animateScrollToPage(index) pagerState.animateScrollToPage(index)
} }
} }
.then( .then(
if (isSelected) { if (isSelected) {
Modifier.background( Modifier.background(
Color.White.copy(alpha = 0.3f), Color.White.copy(
RoundedCornerShape(8.dp) alpha = 0.3f
) ),
} else Modifier RoundedCornerShape(8.dp)
), )
contentScale = ContentScale.Crop } else Modifier
),
contentScale = ContentScale.Crop
) )
} }
} }
@@ -162,48 +135,3 @@ fun FullscreenImageViewer(
} }
} }
} }
@Composable
private fun ZoomableImage(
imagePath: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageFile = ImageUtils.getImageFile(context, imagePath)
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Box(
modifier = modifier
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
val maxOffsetX = (size.width * (scale - 1)) / 2
val maxOffsetY = (size.height * (scale - 1)) / 2
offsetX = (offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
offsetY = (offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
}
)
},
contentAlignment = Alignment.Center
) {
AsyncImage(
model = imageFile,
contentDescription = "Full screen image",
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
contentScale = ContentScale.Fit
)
}
}

View File

@@ -12,36 +12,29 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils
@Composable @Composable
fun ImageDisplay( fun ImageDisplay(
imagePaths: List<String>, imagePaths: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageSize: Int = 120, imageSize: Int = 120,
onImageClick: ((Int) -> Unit)? = null onImageClick: ((Int) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
if (imagePaths.isNotEmpty()) { if (imagePaths.isNotEmpty()) {
LazyRow( LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(imagePaths) { index, imagePath -> itemsIndexed(imagePaths) { index, imagePath ->
val imageFile = ImageUtils.getImageFile(context, imagePath) OrientationAwareImage(
imagePath = imagePath,
AsyncImage( contentDescription = "Problem photo",
model = imageFile, modifier =
contentDescription = "Problem photo", Modifier.size(imageSize.dp)
modifier = Modifier .clip(RoundedCornerShape(8.dp))
.size(imageSize.dp) .clickable(enabled = onImageClick != null) {
.clip(RoundedCornerShape(8.dp)) onImageClick?.invoke(index)
.clickable(enabled = onImageClick != null) { },
onImageClick?.invoke(index) contentScale = ContentScale.Crop
},
contentScale = ContentScale.Crop
) )
} }
} }
@@ -50,26 +43,22 @@ fun ImageDisplay(
@Composable @Composable
fun ImageDisplaySection( fun ImageDisplaySection(
imagePaths: List<String>, imagePaths: List<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String = "Photos", title: String = "Photos",
onImageClick: ((Int) -> Unit)? = null onImageClick: ((Int) -> Unit)? = null
) { ) {
if (imagePaths.isNotEmpty()) { if (imagePaths.isNotEmpty()) {
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
ImageDisplay( ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
imagePaths = imagePaths,
imageSize = 120,
onImageClick = onImageClick
)
} }
} }
} }

View File

@@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import com.atridad.openclimb.utils.ImageUtils import com.atridad.openclimb.utils.ImageUtils
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
val imageFile = ImageUtils.getImageFile(context, imagePath) val imageFile = ImageUtils.getImageFile(context, imagePath)
Box(modifier = modifier.size(80.dp)) { Box(modifier = modifier.size(80.dp)) {
AsyncImage( OrientationAwareImage(
model = imageFile, imagePath = imagePath,
contentDescription = "Problem photo", contentDescription = "Problem photo",
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -0,0 +1,149 @@
package com.atridad.openclimb.ui.components
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.exifinterface.media.ExifInterface
import com.atridad.openclimb.utils.ImageUtils
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun OrientationAwareImage(
imagePath: String,
contentDescription: String? = null,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
val context = LocalContext.current
var imageBitmap by
remember(imagePath) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var isLoading by remember(imagePath) { mutableStateOf(true) }
LaunchedEffect(imagePath) {
isLoading = true
val bitmap =
withContext(Dispatchers.IO) {
try {
val imageFile = ImageUtils.getImageFile(context, imagePath)
if (!imageFile.exists()) return@withContext null
val originalBitmap =
BitmapFactory.decodeFile(imageFile.absolutePath)
?: return@withContext null
val correctedBitmap = correctImageOrientation(imageFile, originalBitmap)
correctedBitmap.asImageBitmap()
} catch (e: Exception) {
null
}
}
imageBitmap = bitmap
isLoading = false
}
Box(modifier = modifier) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize())
} else {
imageBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale
)
}
}
}
}
private fun correctImageOrientation(
imageFile: File,
bitmap: android.graphics.Bitmap
): android.graphics.Bitmap {
return try {
val exif = ExifInterface(imageFile.absolutePath)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
val matrix = Matrix()
var needsTransform = false
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> {
matrix.postRotate(90f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_180 -> {
matrix.postRotate(180f)
needsTransform = true
}
ExifInterface.ORIENTATION_ROTATE_270 -> {
matrix.postRotate(270f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
matrix.postScale(1f, -1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
needsTransform = true
}
else -> {
if (orientation == ExifInterface.ORIENTATION_UNDEFINED || orientation == 0) {
if (imageFile.name.startsWith("problem_") &&
imageFile.name.contains("_") &&
imageFile.name.endsWith(".jpg")
) {
matrix.postRotate(90f)
needsTransform = true
}
}
}
}
if (!needsTransform) {
bitmap
} else {
val rotatedBitmap =
android.graphics.Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
}
} catch (e: Exception) {
bitmap
}
}

View File

@@ -38,6 +38,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
val isTesting by syncService.isTesting.collectAsState() val isTesting by syncService.isTesting.collectAsState()
val lastSyncTime by syncService.lastSyncTime.collectAsState() val lastSyncTime by syncService.lastSyncTime.collectAsState()
val syncError by syncService.syncError.collectAsState() val syncError by syncService.syncError.collectAsState()
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
// State for dialogs // State for dialogs
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
@@ -280,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Switch( Switch(
checked = syncService.isAutoSyncEnabled, checked = isAutoSyncEnabled,
onCheckedChange = { syncService.isAutoSyncEnabled = it } onCheckedChange = { enabled ->
syncService.setAutoSyncEnabled(enabled)
}
) )
} }
} }

View File

@@ -8,6 +8,7 @@ import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
@@ -27,7 +28,57 @@ object ImageUtils {
return imagesDir return imagesDir
} }
/** Saves an image from a URI with compression and proper orientation */ /** Saves an image from a URI while preserving EXIF orientation data */
private fun saveImageWithExif(
context: Context,
imageUri: Uri,
originalBitmap: Bitmap,
outputFile: File
): Boolean {
return try {
// Get EXIF data from original image
val originalExif =
context.contentResolver.openInputStream(imageUri)?.use { input ->
ExifInterface(input)
}
// Compress and save the bitmap
val compressedBitmap = compressImage(originalBitmap)
FileOutputStream(outputFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Copy EXIF data to the saved file
originalExif?.let { sourceExif ->
val destExif = ExifInterface(outputFile.absolutePath)
// Copy orientation and other important EXIF attributes
val orientationValue = sourceExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let { destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it) }
// Copy other useful EXIF data
sourceExif.getAttribute(ExifInterface.TAG_DATETIME)?.let {
destExif.setAttribute(ExifInterface.TAG_DATETIME, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, it)
}
sourceExif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)?.let {
destExif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, it)
}
destExif.saveAttributes()
}
compressedBitmap.recycle()
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/** Saves an image from a URI with compression */
fun saveImageFromUri( fun saveImageFromUri(
context: Context, context: Context,
imageUri: Uri, imageUri: Uri,
@@ -42,10 +93,7 @@ object ImageUtils {
} }
?: return null ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap) // Always require deterministic naming
val compressedBitmap = compressImage(orientedBitmap)
// Always require deterministic naming - no UUID fallback
require(problemId != null && imageIndex != null) { require(problemId != null && imageIndex != null) {
"Problem ID and image index are required for deterministic image naming" "Problem ID and image index are required for deterministic image naming"
} }
@@ -53,15 +101,10 @@ object ImageUtils {
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex) val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
FileOutputStream(imageFile).use { output -> val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() if (!success) return null
}
compressedBitmap.recycle()
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"
} catch (e: Exception) { } catch (e: Exception) {
@@ -221,23 +264,13 @@ object ImageUtils {
MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri) MediaStore.Images.Media.getBitmap(context.contentResolver, imageUri)
?: return null ?: return null
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
val tempFilename = "temp_${UUID.randomUUID()}.jpg" val tempFilename = "temp_${UUID.randomUUID()}.jpg"
val imageFile = File(getImagesDirectory(context), tempFilename) val imageFile = File(getImagesDirectory(context), tempFilename)
FileOutputStream(imageFile).use { output -> val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
originalBitmap.recycle() originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle() if (!success) return null
}
if (compressedBitmap != orientedBitmap) {
compressedBitmap.recycle()
}
tempFilename tempFilename
} catch (e: Exception) { } catch (e: Exception) {
@@ -315,21 +348,40 @@ object ImageUtils {
filename: String filename: String
): String? { ): String? {
return try { return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Use the provided filename instead of generating a new UUID
val imageFile = File(getImagesDirectory(context), filename) val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image // Check if image is too large and needs compression
FileOutputStream(imageFile).use { output -> if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output) // For large images, decode, compress, and try to preserve EXIF
} val bitmap =
BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
val compressedBitmap = compressImage(bitmap)
// Clean up bitmaps // Save compressed image
bitmap.recycle() FileOutputStream(imageFile).use { output ->
compressedBitmap.recycle() compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Try to preserve EXIF orientation from original data
try {
val originalExif = ExifInterface(java.io.ByteArrayInputStream(imageData))
val destExif = ExifInterface(imageFile.absolutePath)
val orientationValue = originalExif.getAttribute(ExifInterface.TAG_ORIENTATION)
orientationValue?.let {
destExif.setAttribute(ExifInterface.TAG_ORIENTATION, it)
}
destExif.saveAttributes()
} catch (e: Exception) {
// If EXIF preservation fails, continue without it
Log.w("ImageUtils", "Failed to preserve EXIF data: ${e.message}")
}
bitmap.recycle()
compressedBitmap.recycle()
} else {
// For smaller images, save raw data to preserve all EXIF information
FileOutputStream(imageFile).use { output -> output.write(imageData) }
}
// Return relative path // Return relative path
"$IMAGES_DIR/$filename" "$IMAGES_DIR/$filename"

View File

@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -513,7 +513,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -602,7 +602,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -632,7 +632,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23; CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import UIKit
class ImageManager { class ImageManager {
static let shared = ImageManager() static let shared = ImageManager()

View File

@@ -0,0 +1,154 @@
import SwiftUI
import UIKit
struct OrientationAwareImage: View {
let imagePath: String
let contentMode: ContentMode
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
init(imagePath: String, contentMode: ContentMode = .fit) {
self.imagePath = imagePath
self.contentMode = contentMode
}
var body: some View {
Group {
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: contentMode)
} else if hasFailed {
Image(systemName: "photo")
.foregroundColor(.gray)
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
.onAppear {
loadImageWithCorrectOrientation()
}
.onChange(of: imagePath) { _ in
loadImageWithCorrectOrientation()
}
}
private func loadImageWithCorrectOrientation() {
Task {
let correctedImage = await loadAndCorrectImage()
await MainActor.run {
self.uiImage = correctedImage
self.isLoading = false
self.hasFailed = correctedImage == nil
}
}
}
private func loadAndCorrectImage() async -> UIImage? {
// Load image data from ImageManager
guard
let data = await MainActor.run(body: {
ImageManager.shared.loadImageData(fromPath: imagePath)
})
else { return nil }
// Create UIImage from data
guard let originalImage = UIImage(data: data) else { return nil }
// Apply orientation correction
return correctImageOrientation(originalImage)
}
/// Corrects the orientation of a UIImage based on its EXIF data
private func correctImageOrientation(_ image: UIImage) -> UIImage {
// If the image is already in the correct orientation, return as-is
if image.imageOrientation == .up {
return image
}
// Calculate the proper transformation matrix
var transform = CGAffineTransform.identity
switch image.imageOrientation {
case .down, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: image.size.height)
transform = transform.rotated(by: .pi)
case .left, .leftMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.rotated(by: .pi / 2)
case .right, .rightMirrored:
transform = transform.translatedBy(x: 0, y: image.size.height)
transform = transform.rotated(by: -.pi / 2)
case .up, .upMirrored:
break
@unknown default:
break
}
switch image.imageOrientation {
case .upMirrored, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .leftMirrored, .rightMirrored:
transform = transform.translatedBy(x: image.size.height, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .up, .down, .left, .right:
break
@unknown default:
break
}
// Create a new image context and apply the transformation
guard let cgImage = image.cgImage else { return image }
let context = CGContext(
data: nil,
width: Int(image.size.width),
height: Int(image.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: cgImage.bitmapInfo.rawValue
)
guard let ctx = context else { return image }
ctx.concatenate(transform)
switch image.imageOrientation {
case .left, .leftMirrored, .right, .rightMirrored:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width))
default:
ctx.draw(
cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
guard let newCGImage = ctx.makeImage() else { return image }
return UIImage(cgImage: newCGImage)
}
}
// MARK: - Convenience Extensions
extension OrientationAwareImage {
/// Creates an orientation-aware image with fill content mode
static func fill(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fill)
}
/// Creates an orientation-aware image with fit content mode
static func fit(imagePath: String) -> OrientationAwareImage {
OrientationAwareImage(imagePath: imagePath, contentMode: .fit)
}
}

View File

@@ -1308,131 +1308,19 @@ struct EditAttemptView: View {
struct ProblemSelectionImageView: View { struct ProblemSelectionImageView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fill(imagePath: imagePath)
if let uiImage = uiImage { .frame(height: 80)
Image(uiImage: uiImage) .clipped()
.resizable() .cornerRadius(8)
.aspectRatio(contentMode: .fill)
.frame(height: 80)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(height: 80)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }
struct ProblemSelectionImageFullView: View { struct ProblemSelectionImageFullView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fit(imagePath: imagePath)
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if hasFailed {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
Text("Image not available")
.foregroundColor(.gray)
}
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
.frame(height: 250)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
DispatchQueue.global(qos: .userInitiated).async {
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
DispatchQueue.main.async {
self.uiImage = image
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }

View File

@@ -443,132 +443,20 @@ struct ImageViewerView: View {
struct ProblemDetailImageView: View { struct ProblemDetailImageView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fill(imagePath: imagePath)
if let uiImage = uiImage { .frame(width: 120, height: 120)
Image(uiImage: uiImage) .clipped()
.resizable() .cornerRadius(12)
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipped()
.cornerRadius(12)
} else if hasFailed {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.2))
.frame(width: 120, height: 120)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title2)
}
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }
struct ProblemDetailImageFullView: View { struct ProblemDetailImageFullView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
var body: some View { var body: some View {
Group { OrientationAwareImage.fit(imagePath: imagePath)
if let uiImage = uiImage {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else if hasFailed {
Rectangle()
.fill(.gray.opacity(0.2))
.frame(height: 250)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
Text("Image not available")
.foregroundColor(.gray)
}
}
} else {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 250)
.overlay {
ProgressView()
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
Task {
let data = await MainActor.run {
ImageManager.shared.loadImageData(fromPath: imagePath)
}
if let data = data, let image = UIImage(data: data) {
await MainActor.run {
self.uiImage = image
self.isLoading = false
}
} else {
await MainActor.run {
self.hasFailed = true
self.isLoading = false
}
}
}
} }
} }

View File

@@ -480,81 +480,12 @@ struct EmptyProblemsView: View {
struct ProblemImageView: View { struct ProblemImageView: View {
let imagePath: String let imagePath: String
@State private var uiImage: UIImage?
@State private var isLoading = true
@State private var hasFailed = false
private static let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
return cache
}()
var body: some View { var body: some View {
Group { OrientationAwareImage.fill(imagePath: imagePath)
if let uiImage = uiImage { .frame(width: 60, height: 60)
Image(uiImage: uiImage) .clipped()
.resizable() .cornerRadius(8)
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
} else if hasFailed {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.title3)
}
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay {
ProgressView()
.scaleEffect(0.8)
}
}
}
.onAppear {
loadImage()
}
}
private func loadImage() {
guard !imagePath.isEmpty else {
hasFailed = true
isLoading = false
return
}
// Load image asynchronously
Task { @MainActor in
let cacheKey = NSString(string: imagePath)
// Check cache first
if let cachedImage = Self.imageCache.object(forKey: cacheKey) {
self.uiImage = cachedImage
self.isLoading = false
return
}
// Load image data
if let data = ImageManager.shared.loadImageData(fromPath: imagePath),
let image = UIImage(data: data)
{
// Cache the image
Self.imageCache.setObject(image, forKey: cacheKey)
self.uiImage = image
self.isLoading = false
} else {
self.hasFailed = true
self.isLoading = false
}
}
} }
} }