[Android] 1.9.1 - EXIF Fixes
This commit is contained in:
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 37
|
||||
versionName = "1.9.0"
|
||||
versionCode = 38
|
||||
versionName = "1.9.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
private val _isTesting = MutableStateFlow(false)
|
||||
val isTesting: StateFlow<Boolean> = _isTesting.asStateFlow()
|
||||
|
||||
private val _isAutoSyncEnabled = MutableStateFlow(true)
|
||||
val isAutoSyncEnabled: StateFlow<Boolean> = _isAutoSyncEnabled.asStateFlow()
|
||||
|
||||
// Debounced sync properties
|
||||
private var syncJob: Job? = null
|
||||
private var pendingChanges = false
|
||||
@@ -130,15 +133,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
_isConfigured.value = serverURL.isNotEmpty() && authToken.isNotEmpty()
|
||||
}
|
||||
|
||||
var isAutoSyncEnabled: Boolean
|
||||
get() = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
|
||||
set(value) {
|
||||
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, value).apply()
|
||||
}
|
||||
fun setAutoSyncEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit().putBoolean(Keys.AUTO_SYNC_ENABLED, enabled).apply()
|
||||
_isAutoSyncEnabled.value = enabled
|
||||
}
|
||||
|
||||
init {
|
||||
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
|
||||
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
|
||||
_isAutoSyncEnabled.value = sharedPreferences.getBoolean(Keys.AUTO_SYNC_ENABLED, true)
|
||||
updateConfiguredState()
|
||||
|
||||
repository.setAutoSyncCallback {
|
||||
@@ -1104,7 +1107,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
}
|
||||
|
||||
suspend fun triggerAutoSync() {
|
||||
if (!isConfigured || !_isConnected.value || !isAutoSyncEnabled) {
|
||||
if (!isConfigured || !_isConnected.value || !_isAutoSyncEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1159,7 +1162,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
|
||||
serverURL = ""
|
||||
authToken = ""
|
||||
isAutoSyncEnabled = true
|
||||
setAutoSyncEnabled(true)
|
||||
_lastSyncTime.value = null
|
||||
_isConnected.value = false
|
||||
_syncError.value = null
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.atridad.openclimb.ui.components
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -20,140 +19,114 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FullscreenImageViewer(
|
||||
imagePaths: List<String>,
|
||||
initialIndex: Int = 0,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
fun FullscreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex,
|
||||
pageCount = { imagePaths.size }
|
||||
)
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { imagePaths.size })
|
||||
val thumbnailListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
|
||||
// Auto-scroll thumbnail list to center current image
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
thumbnailListState.animateScrollToItem(
|
||||
index = pagerState.currentPage,
|
||||
scrollOffset = -200
|
||||
)
|
||||
thumbnailListState.animateScrollToItem(index = pagerState.currentPage, scrollOffset = -200)
|
||||
}
|
||||
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
// Main image pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imagePath = imagePaths[page],
|
||||
modifier = Modifier.fillMaxSize()
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePaths[page],
|
||||
contentDescription = "Full screen image",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Close button
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
onClick = onDismiss,
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
) { Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) }
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
text = "${pagerState.currentPage + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Thumbnail strip (if multiple images)
|
||||
if (imagePaths.size > 1) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
LazyRow(
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
state = thumbnailListState,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
val isSelected = index == pagerState.currentPage
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Thumbnail ${index + 1}",
|
||||
modifier =
|
||||
Modifier.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.background(
|
||||
Color.White.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,36 +12,29 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
|
||||
@Composable
|
||||
fun ImageDisplay(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
imageSize: Int = 120,
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LazyRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(imagePaths) { index, imagePath ->
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier
|
||||
.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier =
|
||||
Modifier.size(imageSize.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = onImageClick != null) {
|
||||
onImageClick?.invoke(index)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,26 +43,22 @@ fun ImageDisplay(
|
||||
|
||||
@Composable
|
||||
fun ImageDisplaySection(
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
imagePaths: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Photos",
|
||||
onImageClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
if (imagePaths.isNotEmpty()) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ImageDisplay(
|
||||
imagePaths = imagePaths,
|
||||
imageSize = 120,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
|
||||
ImageDisplay(imagePaths = imagePaths, imageSize = 120, onImageClick = onImageClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import coil.compose.AsyncImage
|
||||
import com.atridad.openclimb.utils.ImageUtils
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -259,8 +258,8 @@ private fun ImageItem(imagePath: String, onRemove: () -> Unit, modifier: Modifie
|
||||
val imageFile = ImageUtils.getImageFile(context, imagePath)
|
||||
|
||||
Box(modifier = modifier.size(80.dp)) {
|
||||
AsyncImage(
|
||||
model = imageFile,
|
||||
OrientationAwareImage(
|
||||
imagePath = imagePath,
|
||||
contentDescription = "Problem photo",
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
val isTesting by syncService.isTesting.collectAsState()
|
||||
val lastSyncTime by syncService.lastSyncTime.collectAsState()
|
||||
val syncError by syncService.syncError.collectAsState()
|
||||
val isAutoSyncEnabled by syncService.isAutoSyncEnabled.collectAsState()
|
||||
|
||||
// State for dialogs
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -280,8 +281,10 @@ fun SettingsScreen(viewModel: ClimbViewModel) {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Switch(
|
||||
checked = syncService.isAutoSyncEnabled,
|
||||
onCheckedChange = { syncService.isAutoSyncEnabled = it }
|
||||
checked = isAutoSyncEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
syncService.setAutoSyncEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.UUID
|
||||
@@ -27,7 +28,57 @@ object ImageUtils {
|
||||
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(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
@@ -42,10 +93,7 @@ object ImageUtils {
|
||||
}
|
||||
?: return null
|
||||
|
||||
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
|
||||
val compressedBitmap = compressImage(orientedBitmap)
|
||||
|
||||
// Always require deterministic naming - no UUID fallback
|
||||
// Always require deterministic naming
|
||||
require(problemId != null && imageIndex != null) {
|
||||
"Problem ID and image index are required for deterministic image naming"
|
||||
}
|
||||
@@ -53,15 +101,10 @@ object ImageUtils {
|
||||
val filename = ImageNamingUtils.generateImageFilename(problemId, imageIndex)
|
||||
val imageFile = File(getImagesDirectory(context), filename)
|
||||
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
|
||||
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
compressedBitmap.recycle()
|
||||
|
||||
if (!success) return null
|
||||
|
||||
"$IMAGES_DIR/$filename"
|
||||
} catch (e: Exception) {
|
||||
@@ -221,23 +264,13 @@ object ImageUtils {
|
||||
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)
|
||||
}
|
||||
|
||||
val success = saveImageWithExif(context, imageUri, originalBitmap, imageFile)
|
||||
originalBitmap.recycle()
|
||||
if (orientedBitmap != originalBitmap) {
|
||||
orientedBitmap.recycle()
|
||||
}
|
||||
if (compressedBitmap != orientedBitmap) {
|
||||
compressedBitmap.recycle()
|
||||
}
|
||||
|
||||
if (!success) return null
|
||||
|
||||
tempFilename
|
||||
} catch (e: Exception) {
|
||||
@@ -315,21 +348,40 @@ object ImageUtils {
|
||||
filename: String
|
||||
): String? {
|
||||
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)
|
||||
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
|
||||
}
|
||||
// Check if image is too large and needs compression
|
||||
if (imageData.size > 5 * 1024 * 1024) { // 5MB threshold
|
||||
// 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
|
||||
bitmap.recycle()
|
||||
compressedBitmap.recycle()
|
||||
// Save compressed image
|
||||
FileOutputStream(imageFile).use { output ->
|
||||
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
|
||||
"$IMAGES_DIR/$filename"
|
||||
|
||||
Reference in New Issue
Block a user