From 780a8ab167effdc3dca9252b7f9c0349bce1e05d Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 3 Mar 2026 22:32:11 +0100 Subject: [PATCH] Add bottom bar gesture exclusion, dual image save, thumbnail preview, and gallery picker - Increase bottom padding and add systemGestureExclusion to prevent accidental navigation gestures from the capture controls - Save both original and processed images with shared timestamps (TILTSHIFT_* and ORIGINAL_*) via new saveBitmapPair() pipeline - Show animated thumbnail of last captured photo at bottom-right; tap opens the image in the default photo viewer - Add gallery picker button to process existing photos through the tilt-shift pipeline with full EXIF rotation support Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 150 ++++++++++++- .../no/naiv/tiltshift/storage/PhotoSaver.kt | 57 +++-- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 201 +++++++++++++++--- 3 files changed, 353 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index 9f5c5f4..3f656fd 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -5,12 +5,15 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.location.Location +import android.net.Uri import android.util.Log import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.storage.PhotoSaver @@ -38,7 +41,10 @@ class ImageCaptureHandler( * camera callback (synchronous CPU work) and consumed afterwards * in the caller's coroutine context. */ - private class ProcessedCapture(val bitmap: Bitmap) + private class ProcessedCapture( + val originalBitmap: Bitmap, + val processedBitmap: Bitmap + ) /** * Captures a photo and applies the tilt-shift effect. @@ -80,10 +86,11 @@ class ImageCaptureHandler( currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) - currentBitmap.recycle() + // Keep originalBitmap alive — both are recycled after saving + val original = currentBitmap currentBitmap = null - continuation.resume(ProcessedCapture(processedBitmap)) + continuation.resume(ProcessedCapture(original, processedBitmap)) } catch (e: Exception) { Log.e(TAG, "Image processing failed", e) currentBitmap?.recycle() @@ -104,22 +111,46 @@ class ImageCaptureHandler( ) } - // Phase 2: save to disk in the caller's coroutine context (suspend-safe) + // Phase 2: save both original and processed to disk (suspend-safe) if (captureResult is ProcessedCapture) { return try { - photoSaver.saveBitmap( - captureResult.bitmap, - ExifInterface.ORIENTATION_NORMAL, - location + val thumbnail = createThumbnail(captureResult.processedBitmap) + val result = photoSaver.saveBitmapPair( + original = captureResult.originalBitmap, + processed = captureResult.processedBitmap, + orientation = ExifInterface.ORIENTATION_NORMAL, + location = location ) + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result + } } finally { - captureResult.bitmap.recycle() + captureResult.originalBitmap.recycle() + captureResult.processedBitmap.recycle() } } return captureResult as SaveResult } + /** + * Creates a small thumbnail copy of a bitmap for in-app preview. + */ + private fun createThumbnail(source: Bitmap, maxSize: Int = 160): Bitmap? { + return try { + val scale = maxSize.toFloat() / maxOf(source.width, source.height) + val width = (source.width * scale).toInt() + val height = (source.height * scale).toInt() + Bitmap.createScaledBitmap(source, width, height, true) + } catch (e: Exception) { + Log.w(TAG, "Failed to create thumbnail", e) + null + } + } + /** * Rotates a bitmap to the correct orientation. */ @@ -158,6 +189,107 @@ class ImageCaptureHandler( return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } + /** + * Processes an existing image from the gallery through the tilt-shift pipeline. + * Loads the image, applies EXIF rotation, processes the effect, and saves both versions. + */ + suspend fun processExistingImage( + imageUri: Uri, + blurParams: BlurParameters, + location: Location? + ): SaveResult = withContext(Dispatchers.IO) { + var originalBitmap: Bitmap? = null + var processedBitmap: Bitmap? = null + try { + originalBitmap = loadBitmapFromUri(imageUri) + ?: return@withContext SaveResult.Error("Failed to load image") + + originalBitmap = applyExifRotation(imageUri, originalBitmap) + + processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams) + + val thumbnail = createThumbnail(processedBitmap) + + val result = photoSaver.saveBitmapPair( + original = originalBitmap, + processed = processedBitmap, + orientation = ExifInterface.ORIENTATION_NORMAL, + location = location + ) + + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result + } + } catch (e: Exception) { + Log.e(TAG, "Gallery image processing failed", e) + SaveResult.Error("Processing failed: ${e.message}", e) + } finally { + originalBitmap?.recycle() + processedBitmap?.recycle() + } + } + + /** + * Loads a bitmap from a content URI. + */ + private fun loadBitmapFromUri(uri: Uri): Bitmap? { + return try { + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load bitmap from URI", e) + null + } + } + + /** + * Reads EXIF orientation from a content URI and applies the + * required rotation/flip to the bitmap. + */ + private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap { + val orientation = try { + context.contentResolver.openInputStream(uri)?.use { stream -> + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } ?: ExifInterface.ORIENTATION_NORMAL + } catch (e: Exception) { + Log.w(TAG, "Failed to read EXIF orientation", e) + ExifInterface.ORIENTATION_NORMAL + } + + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.postRotate(90f) + matrix.postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.postRotate(270f) + matrix.postScale(-1f, 1f) + } + else -> return bitmap + } + + val rotated = Bitmap.createBitmap( + bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true + ) + if (rotated != bitmap) { + bitmap.recycle() + } + return rotated + } + /** * Applies tilt-shift blur effect to a bitmap. * Supports both linear and radial modes. diff --git a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt index aa56f2e..4bbe9b5 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -21,7 +21,12 @@ import java.util.Locale * Result of a photo save operation. */ sealed class SaveResult { - data class Success(val uri: Uri, val path: String) : SaveResult() + data class Success( + val uri: Uri, + val path: String, + val originalUri: Uri? = null, + val thumbnail: android.graphics.Bitmap? = null + ) : SaveResult() data class Error(val message: String, val exception: Exception? = null) : SaveResult() } @@ -44,10 +49,44 @@ class PhotoSaver(private val context: Context) { orientation: Int, location: Location? ): SaveResult = withContext(Dispatchers.IO) { - try { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" + saveSingleBitmap(fileName, bitmap, orientation, location) + } - // Create content values for MediaStore + /** + * Saves both original and processed bitmaps to the gallery. + * Uses a shared timestamp so paired files sort together. + * Returns the processed image's URI as the primary result. + */ + suspend fun saveBitmapPair( + original: Bitmap, + processed: Bitmap, + orientation: Int, + location: Location? + ): SaveResult = withContext(Dispatchers.IO) { + val timestamp = fileNameFormat.format(Date()) + val processedFileName = "TILTSHIFT_${timestamp}.jpg" + val originalFileName = "ORIGINAL_${timestamp}.jpg" + + val processedResult = saveSingleBitmap(processedFileName, processed, orientation, location) + if (processedResult is SaveResult.Error) return@withContext processedResult + + val originalResult = saveSingleBitmap(originalFileName, original, orientation, location) + val originalUri = (originalResult as? SaveResult.Success)?.uri + + (processedResult as SaveResult.Success).copy(originalUri = originalUri) + } + + /** + * Core save logic: writes a single bitmap to MediaStore with EXIF data. + */ + private fun saveSingleBitmap( + fileName: String, + bitmap: Bitmap, + orientation: Int, + location: Location? + ): SaveResult { + return try { val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, fileName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") @@ -57,29 +96,23 @@ class PhotoSaver(private val context: Context) { put(MediaStore.Images.Media.IS_PENDING, 1) } - // Insert into MediaStore val contentResolver = context.contentResolver val uri = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues - ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry") + ) ?: return SaveResult.Error("Failed to create MediaStore entry") - // Write bitmap to output stream contentResolver.openOutputStream(uri)?.use { outputStream -> bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) - } ?: return@withContext SaveResult.Error("Failed to open output stream") + } ?: return SaveResult.Error("Failed to open output stream") - // Write EXIF data writeExifToUri(uri, orientation, location) - // Mark as complete contentValues.clear() contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentResolver.update(uri, contentValues, null, null) - // Get the file path for display val path = getPathFromUri(uri) - SaveResult.Success(uri, path) } catch (e: Exception) { SaveResult.Error("Failed to save photo: ${e.message}", e) diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index a069922..790857c 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,15 +1,20 @@ package no.naiv.tiltshift.ui +import android.content.Intent +import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.location.Location +import android.net.Uri import android.opengl.GLSurfaceView import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,10 +30,14 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Slider @@ -46,8 +55,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.Image import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.unit.dp @@ -97,9 +109,47 @@ fun CameraScreen( var showSaveError by remember { mutableStateOf(null) } var showControls by remember { mutableStateOf(false) } + // Thumbnail state for last captured photo + var lastSavedUri by remember { mutableStateOf(null) } + var lastThumbnailBitmap by remember { mutableStateOf(null) } + var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentLocation by remember { mutableStateOf(null) } + // Gallery picker: process a selected image through the tilt-shift pipeline + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null && !isCapturing) { + isCapturing = true + scope.launch { + val result = captureHandler.processExistingImage( + imageUri = uri, + blurParams = blurParams, + location = currentLocation + ) + when (result) { + is SaveResult.Success -> { + haptics.success() + lastThumbnailBitmap?.recycle() + lastThumbnailBitmap = result.thumbnail + lastSavedUri = result.uri + showSaveSuccess = true + delay(1500) + showSaveSuccess = false + } + is SaveResult.Error -> { + haptics.error() + showSaveError = result.message + delay(2000) + showSaveError = null + } + } + isCapturing = false + } + } + } + val zoomRatio by cameraManager.zoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState() @@ -154,6 +204,7 @@ fun CameraScreen( onDispose { cameraManager.release() renderer?.release() + lastThumbnailBitmap?.recycle() } } @@ -281,7 +332,8 @@ fun CameraScreen( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() - .padding(bottom = 24.dp), + .padding(bottom = 48.dp) + .systemGestureExclusion(), horizontalAlignment = Alignment.CenterHorizontally ) { // Zoom presets (only show for back camera) @@ -299,48 +351,98 @@ fun CameraScreen( Spacer(modifier = Modifier.height(24.dp)) } - // Capture button - CaptureButton( - isCapturing = isCapturing, - onClick = { - if (!isCapturing) { - isCapturing = true - haptics.heavyClick() + // Gallery button | Capture button | Spacer for symmetry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Gallery picker button + IconButton( + onClick = { + if (!isCapturing) { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + }, + enabled = !isCapturing, + modifier = Modifier.size(52.dp) + ) { + Icon( + imageVector = Icons.Default.PhotoLibrary, + contentDescription = "Pick from gallery", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } - scope.launch { - val imageCapture = cameraManager.imageCapture - if (imageCapture != null) { - val result = captureHandler.capturePhoto( - imageCapture = imageCapture, - executor = cameraManager.getExecutor(), - blurParams = blurParams, - deviceRotation = currentRotation, - location = currentLocation, - isFrontCamera = isFrontCamera - ) + // Capture button + CaptureButton( + isCapturing = isCapturing, + onClick = { + if (!isCapturing) { + isCapturing = true + haptics.heavyClick() - when (result) { - is SaveResult.Success -> { - haptics.success() - showSaveSuccess = true - delay(1500) - showSaveSuccess = false - } - is SaveResult.Error -> { - haptics.error() - showSaveError = result.message - delay(2000) - showSaveError = null + scope.launch { + val imageCapture = cameraManager.imageCapture + if (imageCapture != null) { + val result = captureHandler.capturePhoto( + imageCapture = imageCapture, + executor = cameraManager.getExecutor(), + blurParams = blurParams, + deviceRotation = currentRotation, + location = currentLocation, + isFrontCamera = isFrontCamera + ) + + when (result) { + is SaveResult.Success -> { + haptics.success() + lastThumbnailBitmap?.recycle() + lastThumbnailBitmap = result.thumbnail + lastSavedUri = result.uri + showSaveSuccess = true + delay(1500) + showSaveSuccess = false + } + is SaveResult.Error -> { + haptics.error() + showSaveError = result.message + delay(2000) + showSaveError = null + } } } + isCapturing = false } - isCapturing = false } } - } - ) + ) + + // Spacer for visual symmetry with gallery button + Spacer(modifier = Modifier.size(52.dp)) + } } + // Last captured photo thumbnail + LastPhotoThumbnail( + thumbnail = lastThumbnailBitmap, + onTap = { + lastSavedUri?.let { uri -> + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "image/jpeg") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(bottom = 48.dp, end = 16.dp) + ) + // Success indicator AnimatedVisibility( visible = showSaveSuccess, @@ -564,3 +666,34 @@ private fun CaptureButton( ) } } + +/** + * Rounded thumbnail of the last captured photo. + * Tapping opens the image in the default photo viewer. + */ +@Composable +private fun LastPhotoThumbnail( + thumbnail: Bitmap?, + onTap: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = thumbnail != null, + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut(), + modifier = modifier + ) { + thumbnail?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Last captured photo", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(10.dp)) + .border(2.dp, Color.White, RoundedCornerShape(10.dp)) + .clickable(onClick = onTap) + ) + } + } +}