From 593f2c5b1f2b8c4a4e5e122a815bae0480861668 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:20:16 +0100 Subject: [PATCH 01/10] Add bitmap safety in applyTiltShiftEffect() Track all intermediate bitmaps with nullable variables and recycle them in a finally block. This prevents native memory leaks when an OOM or other exception occurs mid-processing. Variables are set to null after recycle or handoff to the caller. Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 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 b7a8d6b..af15e10 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -152,63 +152,81 @@ class ImageCaptureHandler( /** * Applies tilt-shift blur effect to a bitmap. * Supports both linear and radial modes. + * + * All intermediate bitmaps are tracked and recycled in a finally block + * so that an OOM or other exception does not leak native memory. */ private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap { val width = source.width val height = source.height - // Create output bitmap - val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + var result: Bitmap? = null + var scaled: Bitmap? = null + var blurred: Bitmap? = null + var blurredFullSize: Bitmap? = null + var mask: Bitmap? = null - // For performance, we use a scaled-down version for blur and composite - val scaleFactor = 4 // Blur a 1/4 size image for speed - val blurredWidth = width / scaleFactor - val blurredHeight = height / scaleFactor + try { + result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - // Create scaled bitmap for blur - val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) + val scaleFactor = 4 + val blurredWidth = width / scaleFactor + val blurredHeight = height / scaleFactor - // Apply stack blur (fast approximation) - val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) - scaled.recycle() + scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - // Scale blurred back up - val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) - blurred.recycle() + blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + scaled.recycle() + scaled = null - // Create gradient mask based on tilt-shift parameters - val mask = createGradientMask(width, height, params) + blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true) + blurred.recycle() + blurred = null - // Composite: blend original with blurred based on mask - val pixels = IntArray(width * height) - val blurredPixels = IntArray(width * height) - val maskPixels = IntArray(width * height) + mask = createGradientMask(width, height, params) - source.getPixels(pixels, 0, width, 0, 0, width, height) - blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height) - mask.getPixels(maskPixels, 0, width, 0, 0, width, height) + // Composite: blend original with blurred based on mask + val pixels = IntArray(width * height) + val blurredPixels = IntArray(width * height) + val maskPixels = IntArray(width * height) - blurredFullSize.recycle() - mask.recycle() + source.getPixels(pixels, 0, width, 0, 0, width, height) + blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height) + mask.getPixels(maskPixels, 0, width, 0, 0, width, height) - for (i in pixels.indices) { - val maskAlpha = (maskPixels[i] and 0xFF) / 255f - val origR = (pixels[i] shr 16) and 0xFF - val origG = (pixels[i] shr 8) and 0xFF - val origB = pixels[i] and 0xFF - val blurR = (blurredPixels[i] shr 16) and 0xFF - val blurG = (blurredPixels[i] shr 8) and 0xFF - val blurB = blurredPixels[i] and 0xFF + blurredFullSize.recycle() + blurredFullSize = null + mask.recycle() + mask = null - val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt() - val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt() - val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt() + for (i in pixels.indices) { + val maskAlpha = (maskPixels[i] and 0xFF) / 255f + val origR = (pixels[i] shr 16) and 0xFF + val origG = (pixels[i] shr 8) and 0xFF + val origB = pixels[i] and 0xFF + val blurR = (blurredPixels[i] shr 16) and 0xFF + val blurG = (blurredPixels[i] shr 8) and 0xFF + val blurB = blurredPixels[i] and 0xFF - pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt() + val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt() + val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt() + + pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + result.setPixels(pixels, 0, width, 0, 0, width, height) + + val output = result + result = null // prevent finally from recycling the returned bitmap + return output + } finally { + result?.recycle() + scaled?.recycle() + blurred?.recycle() + blurredFullSize?.recycle() + mask?.recycle() } - - result.setPixels(pixels, 0, width, 0, 0, width, height) - return result } /** From f0249fcd647f377e14c704b6714e8100903001f3 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:20:57 +0100 Subject: [PATCH 02/10] Add bitmap safety in onCaptureSuccess callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the current bitmap through the decode→rotate→effect pipeline with a nullable variable. On exception, the in-flight bitmap is recycled in the catch block to prevent native memory leaks. Errors are now logged with Log.e and a proper companion TAG. Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 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 af15e10..9f5c5f4 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.location.Location +import android.util.Log import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy @@ -28,6 +29,10 @@ class ImageCaptureHandler( private val photoSaver: PhotoSaver ) { + companion object { + private const val TAG = "ImageCaptureHandler" + } + /** * Holds the processed bitmap ready for saving, produced inside the * camera callback (synchronous CPU work) and consumed afterwards @@ -58,26 +63,30 @@ class ImageCaptureHandler( executor, object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(imageProxy: ImageProxy) { + var currentBitmap: Bitmap? = null try { val imageRotation = imageProxy.imageInfo.rotationDegrees - var bitmap = imageProxyToBitmap(imageProxy) + currentBitmap = imageProxyToBitmap(imageProxy) imageProxy.close() - if (bitmap == null) { + if (currentBitmap == null) { continuation.resume( SaveResult.Error("Failed to convert image") as Any ) return } - bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) + currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) - val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) - bitmap.recycle() + val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) + currentBitmap.recycle() + currentBitmap = null continuation.resume(ProcessedCapture(processedBitmap)) } catch (e: Exception) { + Log.e(TAG, "Image processing failed", e) + currentBitmap?.recycle() continuation.resume( SaveResult.Error("Capture failed: ${e.message}", e) as Any ) From 6ed3e8e7b55b61f05779011434312281cc00eb7f Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:21:38 +0100 Subject: [PATCH 03/10] Propagate camera binding errors to UI Add an error StateFlow to CameraManager so camera binding failures are surfaced to the user instead of silently swallowed by e.printStackTrace(). CameraScreen collects this flow and displays errors using the existing red overlay UI. Added Log.e with proper TAG for logcat visibility. Co-Authored-By: Claude Opus 4.6 --- .../no/naiv/tiltshift/camera/CameraManager.kt | 16 ++++++++++++++-- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 11 +++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index f70d6a4..ec5ff15 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,6 +2,7 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.SurfaceTexture +import android.util.Log import android.util.Size import android.view.Surface import androidx.camera.core.Camera @@ -24,6 +25,10 @@ import java.util.concurrent.Executor */ class CameraManager(private val context: Context) { + companion object { + private const val TAG = "CameraManager" + } + private var cameraProvider: ProcessCameraProvider? = null private var camera: Camera? = null private var preview: Preview? = null @@ -32,6 +37,13 @@ class CameraManager(private val context: Context) { val lensController = LensController() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun clearError() { + _error.value = null + } + private val _zoomRatio = MutableStateFlow(1.0f) val zoomRatio: StateFlow = _zoomRatio.asStateFlow() @@ -117,8 +129,8 @@ class CameraManager(private val context: Context) { } } catch (e: Exception) { - // Camera binding failed - e.printStackTrace() + Log.e(TAG, "Camera binding failed", e) + _error.value = "Camera failed: ${e.message}" } } 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 c667bca..fb49038 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -104,6 +104,17 @@ fun CameraScreen( val minZoom by cameraManager.minZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState() val isFrontCamera by cameraManager.isFrontCamera.collectAsState() + val cameraError by cameraManager.error.collectAsState() + + // Show camera errors via the existing error UI + LaunchedEffect(cameraError) { + cameraError?.let { message -> + showSaveError = message + cameraManager.clearError() + delay(2000) + showSaveError = null + } + } // Collect orientation updates LaunchedEffect(Unit) { From ef350e9fb7413add3883cb15102ae8080ccb5989 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:22:04 +0100 Subject: [PATCH 04/10] Replace e.printStackTrace() with Log.w in PhotoSaver EXIF write failures are non-critical (the photo is already saved) but should still be visible in logcat. Use Log.w with a proper TAG instead of printStackTrace(). Co-Authored-By: Claude Opus 4.6 --- app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 968ef55..74ec8ea 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -11,6 +11,7 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,6 +34,10 @@ sealed class SaveResult { */ class PhotoSaver(private val context: Context) { + companion object { + private const val TAG = "PhotoSaver" + } + private val exifWriter = ExifWriter() private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) @@ -169,7 +174,7 @@ class PhotoSaver(private val context: Context) { exif.saveAttributes() } } catch (e: Exception) { - e.printStackTrace() + Log.w(TAG, "Failed to write EXIF data", e) } } From 41a95885c105797b1ab0731374de013382fb96ab Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:24:17 +0100 Subject: [PATCH 05/10] Remove dead code - Delete ExifWriter.kt (instantiated but never called) - Remove saveJpegFile() and unused imports from PhotoSaver - Remove CameraFlipButton() and unused imports from LensSwitcher - Remove companion object and unused imports from HapticFeedback - Remove getZoomPresets() from LensController - Update README to reflect ExifWriter removal and actual minSdk (35) Co-Authored-By: Claude Opus 4.6 --- README.md | 5 +- .../naiv/tiltshift/camera/LensController.kt | 7 -- .../no/naiv/tiltshift/storage/ExifWriter.kt | 96 ------------------- .../no/naiv/tiltshift/storage/PhotoSaver.kt | 62 ------------ .../java/no/naiv/tiltshift/ui/LensSwitcher.kt | 28 ------ .../no/naiv/tiltshift/util/HapticFeedback.kt | 11 --- 6 files changed, 2 insertions(+), 207 deletions(-) delete mode 100644 app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt diff --git a/README.md b/README.md index 2fabd70..7668f6c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A dedicated Android camera app for tilt-shift photography with real-time preview ## Requirements -- Android 8.0 (API 26) or higher +- Android 15 (API 35) or higher - Device with camera - OpenGL ES 2.0 support @@ -66,8 +66,7 @@ app/src/main/java/no/naiv/tiltshift/ │ ├── ZoomControl.kt # Zoom UI component │ └── LensSwitcher.kt # Lens selection UI ├── storage/ -│ ├── PhotoSaver.kt # MediaStore integration -│ └── ExifWriter.kt # EXIF metadata handling +│ └── PhotoSaver.kt # MediaStore integration & EXIF handling └── util/ ├── OrientationDetector.kt ├── LocationProvider.kt diff --git a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt index 1939561..7fb7edc 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/LensController.kt @@ -88,11 +88,4 @@ class LensController { return availableLenses[currentLensIndex] } - /** - * Common zoom levels that can be achieved through digital zoom. - * These are presented as quick-select buttons. - */ - fun getZoomPresets(): List { - return listOf(0.5f, 1.0f, 2.0f, 5.0f) - } } diff --git a/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt b/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt deleted file mode 100644 index 4a7431a..0000000 --- a/app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt +++ /dev/null @@ -1,96 +0,0 @@ -package no.naiv.tiltshift.storage - -import android.location.Location -import androidx.exifinterface.media.ExifInterface -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Writes EXIF metadata to captured images. - */ -class ExifWriter { - - private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US) - - /** - * Writes EXIF data to the specified image file. - */ - fun writeExifData( - file: File, - orientation: Int, - location: Location?, - make: String = "Android", - model: String = android.os.Build.MODEL - ) { - try { - val exif = ExifInterface(file) - - // Orientation - exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString()) - - // Date/time - val dateTime = dateTimeFormat.format(Date()) - exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime) - exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime) - exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime) - - // Camera info - exif.setAttribute(ExifInterface.TAG_MAKE, make) - exif.setAttribute(ExifInterface.TAG_MODEL, model) - exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera") - - // GPS location - if (location != null) { - setLocationExif(exif, location) - } - - exif.saveAttributes() - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun setLocationExif(exif: ExifInterface, location: Location) { - // Latitude - val latitude = location.latitude - val latRef = if (latitude >= 0) "N" else "S" - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude))) - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef) - - // Longitude - val longitude = location.longitude - val lonRef = if (longitude >= 0) "E" else "W" - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude))) - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef) - - // Altitude - if (location.hasAltitude()) { - val altitude = location.altitude - val altRef = if (altitude >= 0) "0" else "1" - exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1") - exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef) - } - - // Timestamp - val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) - val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US) - val timestamp = Date(location.time) - exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp)) - exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp)) - } - - /** - * Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF. - */ - private fun convertToDMS(coordinate: Double): String { - val degrees = coordinate.toInt() - val minutesDecimal = (coordinate - degrees) * 60 - val minutes = minutesDecimal.toInt() - val seconds = (minutesDecimal - minutes) * 60 - - // EXIF format: "degrees/1,minutes/1,seconds/1000" - return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000" - } -} 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 74ec8ea..e6d6ee5 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -3,9 +3,6 @@ package no.naiv.tiltshift.storage import android.content.ContentValues import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Paint import android.location.Location import android.net.Uri import android.os.Build @@ -15,8 +12,6 @@ import android.util.Log import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -38,8 +33,6 @@ class PhotoSaver(private val context: Context) { private const val TAG = "PhotoSaver" } - private val exifWriter = ExifWriter() - private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) /** @@ -97,61 +90,6 @@ class PhotoSaver(private val context: Context) { } } - /** - * Saves a JPEG file (from CameraX ImageCapture) to the gallery. - */ - suspend fun saveJpegFile( - sourceFile: File, - orientation: Int, - location: Location? - ): SaveResult = withContext(Dispatchers.IO) { - try { - val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg" - - val contentValues = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, fileName) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) - put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") - put(MediaStore.Images.Media.IS_PENDING, 1) - } - } - - val contentResolver = context.contentResolver - val uri = contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - ) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry") - - // Copy file to MediaStore - contentResolver.openOutputStream(uri)?.use { outputStream -> - sourceFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) - } - } ?: return@withContext SaveResult.Error("Failed to open output stream") - - // Write EXIF data - writeExifToUri(uri, orientation, location) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, contentValues, null, null) - } - - // Clean up source file - sourceFile.delete() - - val path = getPathFromUri(uri) - SaveResult.Success(uri, path) - } catch (e: Exception) { - SaveResult.Error("Failed to save photo: ${e.message}", e) - } - } - private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) { try { context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd -> diff --git a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt index d356512..c2ca806 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraRear -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,28 +87,3 @@ private fun LensButton( ) } } - -/** - * Simple camera flip button (for future front camera support). - */ -@Composable -fun CameraFlipButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .size(48.dp) - .clip(CircleShape) - .background(Color(0x80000000)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.CameraRear, - contentDescription = "Switch Camera", - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } -} diff --git a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt index 551ede7..92247b0 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -5,8 +5,6 @@ import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager -import android.view.HapticFeedbackConstants -import android.view.View /** * Provides haptic feedback for user interactions. @@ -86,13 +84,4 @@ class HapticFeedback(private val context: Context) { vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1) } } - - companion object { - /** - * Use system haptic feedback on a View for standard interactions. - */ - fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) { - view.performHapticFeedback(feedbackConstant) - } - } } From c7fa8f16befdf02bca242c2f8cd591f81a42ef44 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:25:16 +0100 Subject: [PATCH 06/10] Remove unnecessary SDK version checks With minSdk=35 all Build.VERSION.SDK_INT checks for API levels below 35 are always true. Remove all version branching in HapticFeedback (API 29/31 checks) and PhotoSaver (API 29 checks). Keep only the modern API calls and drop @Suppress("DEPRECATION") annotations. Co-Authored-By: Claude Opus 4.6 --- .../no/naiv/tiltshift/storage/PhotoSaver.kt | 18 +++--- .../no/naiv/tiltshift/util/HapticFeedback.kt | 56 ++++--------------- 2 files changed, 19 insertions(+), 55 deletions(-) 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 e6d6ee5..aa56f2e 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -16,6 +16,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale + /** * Result of a photo save operation. */ @@ -52,11 +53,8 @@ class PhotoSaver(private val context: Context) { put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") - put(MediaStore.Images.Media.IS_PENDING, 1) - } + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift") + put(MediaStore.Images.Media.IS_PENDING, 1) } // Insert into MediaStore @@ -74,12 +72,10 @@ class PhotoSaver(private val context: Context) { // Write EXIF data writeExifToUri(uri, orientation, location) - // Mark as complete (API 29+) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentValues.clear() - contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, contentValues, null, null) - } + // 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) diff --git a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt index 92247b0..801b275 100644 --- a/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt +++ b/app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt @@ -1,9 +1,7 @@ package no.naiv.tiltshift.util import android.content.Context -import android.os.Build import android.os.VibrationEffect -import android.os.Vibrator import android.os.VibratorManager /** @@ -11,77 +9,47 @@ import android.os.VibratorManager */ class HapticFeedback(private val context: Context) { - private val vibrator: Vibrator by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - vibratorManager.defaultVibrator - } else { - @Suppress("DEPRECATION") - context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - } + private val vibrator by lazy { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator } /** * Light tick for UI feedback (button press, slider change). */ fun tick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(10L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)) } /** * Click feedback for confirmations. */ fun click() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(20L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) } /** * Heavy click for important actions (photo capture). */ fun heavyClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(40L) - } + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK)) } /** * Success feedback pattern. */ fun success() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val timings = longArrayOf(0, 30, 50, 30) - val amplitudes = intArrayOf(0, 100, 0, 200) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1) - } + val timings = longArrayOf(0, 30, 50, 30) + val amplitudes = intArrayOf(0, 100, 0, 200) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } /** * Error feedback pattern. */ fun error() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val timings = longArrayOf(0, 50, 30, 50, 30, 50) - val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) - vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1) - } + val timings = longArrayOf(0, 50, 30, 50, 30, 50) + val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) } } From 5cba2fefc94e3746725fabffd556851e8029d184 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:38:33 +0100 Subject: [PATCH 07/10] Add configurable high-resolution capture option Add useHighResCapture toggle to CameraManager that switches between CameraX default resolution and HIGHEST_AVAILABLE_STRATEGY. Default is off to avoid OOM from processing very large bitmaps (e.g. 50MP). Co-Authored-By: Claude Opus 4.6 --- .../no/naiv/tiltshift/camera/CameraManager.kt | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index ec5ff15..921c0cd 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -12,6 +12,7 @@ import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner @@ -37,6 +38,20 @@ class CameraManager(private val context: Context) { val lensController = LensController() + private val _useHighResCapture = MutableStateFlow(false) + val useHighResCapture: StateFlow = _useHighResCapture.asStateFlow() + + /** + * Toggles between CameraX default resolution and highest available. + * Rebinds camera use cases to apply the change. + */ + fun setHighResCapture(enabled: Boolean) { + if (_useHighResCapture.value != enabled) { + _useHighResCapture.value = enabled + lifecycleOwnerRef?.let { bindCameraUseCases(it) } + } + } + private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() @@ -94,10 +109,18 @@ class CameraManager(private val context: Context) { .setResolutionSelector(resolutionSelector) .build() - // Image capture use case for high-res photos - imageCapture = ImageCapture.Builder() + // Image capture use case + val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) - .build() + + if (_useHighResCapture.value) { + val captureResolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .build() + captureBuilder.setResolutionSelector(captureResolutionSelector) + } + + imageCapture = captureBuilder.build() // Select camera based on front/back preference val cameraSelector = if (_isFrontCamera.value) { From 7abb2ea5a0216d95d4f58148e7cb82325bf48411 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:50:53 +0100 Subject: [PATCH 08/10] Remove unused high-resolution capture option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature provided no benefit on Pixel 7 Pro — both standard and hi-res modes produced 12MP images since CameraX's standard resolution list doesn't include the full sensor output. Co-Authored-By: Claude Opus 4.6 --- .../no/naiv/tiltshift/camera/CameraManager.kt | 22 ------------------- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 1 + 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 921c0cd..b2d4e6c 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -12,7 +12,6 @@ import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector -import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner @@ -38,20 +37,6 @@ class CameraManager(private val context: Context) { val lensController = LensController() - private val _useHighResCapture = MutableStateFlow(false) - val useHighResCapture: StateFlow = _useHighResCapture.asStateFlow() - - /** - * Toggles between CameraX default resolution and highest available. - * Rebinds camera use cases to apply the change. - */ - fun setHighResCapture(enabled: Boolean) { - if (_useHighResCapture.value != enabled) { - _useHighResCapture.value = enabled - lifecycleOwnerRef?.let { bindCameraUseCases(it) } - } - } - private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() @@ -113,13 +98,6 @@ class CameraManager(private val context: Context) { val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) - if (_useHighResCapture.value) { - val captureResolutionSelector = ResolutionSelector.Builder() - .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) - .build() - captureBuilder.setResolutionSelector(captureResolutionSelector) - } - imageCapture = captureBuilder.build() // Select camera based on front/back preference 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 fb49038..a069922 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -495,6 +495,7 @@ private fun ControlPanel( onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } ) } + } } From 780a8ab167effdc3dca9252b7f9c0349bce1e05d Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 3 Mar 2026 22:32:11 +0100 Subject: [PATCH 09/10] 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) + ) + } + } +} From 5d80dcfcbef47252a90fbba9705cc868f9ee39a0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 5 Mar 2026 11:46:05 +0100 Subject: [PATCH 10/10] Add interactive gallery preview before applying tilt-shift effect Instead of immediately processing gallery images, show a preview where users can adjust blur parameters before committing. Adds Cancel/Apply buttons and hides camera-only controls during gallery preview mode. Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 15 + .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 334 +++++++++++------- 2 files changed, 229 insertions(+), 120 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 3f656fd..eea6c7f 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -189,6 +189,21 @@ class ImageCaptureHandler( return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } + /** + * Loads a gallery image and applies EXIF rotation, returning the bitmap for preview. + * The caller owns the returned bitmap and is responsible for recycling it. + */ + suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) { + try { + val bitmap = loadBitmapFromUri(imageUri) + ?: return@withContext null + applyExifRotation(imageUri, bitmap) + } catch (e: Exception) { + Log.e(TAG, "Failed to load gallery image for preview", e) + null + } + } + /** * 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. 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 790857c..a01d11e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -113,39 +113,30 @@ fun CameraScreen( var lastSavedUri by remember { mutableStateOf(null) } var lastThumbnailBitmap by remember { mutableStateOf(null) } + // Gallery preview mode: non-null means we're previewing a gallery image + var galleryBitmap by remember { mutableStateOf(null) } + var galleryImageUri by remember { mutableStateOf(null) } + val isGalleryPreview = galleryBitmap != 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 + // Gallery picker: load image for interactive preview before processing val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> - if (uri != null && !isCapturing) { - isCapturing = true + if (uri != null && !isCapturing && !isGalleryPreview) { 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 - } + val bitmap = captureHandler.loadGalleryImage(uri) + if (bitmap != null) { + galleryBitmap = bitmap + galleryImageUri = uri + } else { + haptics.error() + showSaveError = "Failed to load image" + delay(2000) + showSaveError = null } - isCapturing = false } } } @@ -205,6 +196,7 @@ fun CameraScreen( cameraManager.release() renderer?.release() lastThumbnailBitmap?.recycle() + galleryBitmap?.recycle() } } @@ -213,25 +205,39 @@ fun CameraScreen( .fillMaxSize() .background(Color.Black) ) { - // OpenGL Surface for camera preview with effect - AndroidView( - factory = { ctx -> - GLSurfaceView(ctx).apply { - setEGLContextClientVersion(2) + // Main view: gallery preview image or camera GL surface + if (isGalleryPreview) { + galleryBitmap?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Gallery preview", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) + } + } else { + // OpenGL Surface for camera preview with effect + AndroidView( + factory = { ctx -> + GLSurfaceView(ctx).apply { + setEGLContextClientVersion(2) - val newRenderer = TiltShiftRenderer(ctx) { st -> - surfaceTexture = st + val newRenderer = TiltShiftRenderer(ctx) { st -> + surfaceTexture = st + } + renderer = newRenderer + + setRenderer(newRenderer) + renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY + + glSurfaceView = this } - renderer = newRenderer - - setRenderer(newRenderer) - renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY - - glSurfaceView = this - } - }, - modifier = Modifier.fillMaxSize() - ) + }, + modifier = Modifier.fillMaxSize() + ) + } // Tilt-shift overlay (gesture handling + visualization) TiltShiftOverlay( @@ -241,8 +247,10 @@ fun CameraScreen( haptics.tick() }, onZoomChange = { zoomDelta -> - val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) - cameraManager.setZoom(newZoom) + if (!isGalleryPreview) { + val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) + cameraManager.setZoom(newZoom) + } }, modifier = Modifier.fillMaxSize() ) @@ -259,22 +267,28 @@ fun CameraScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Zoom indicator - ZoomIndicator(currentZoom = zoomRatio) + if (!isGalleryPreview) { + // Zoom indicator + ZoomIndicator(currentZoom = zoomRatio) + } else { + Spacer(modifier = Modifier.width(1.dp)) + } Row(verticalAlignment = Alignment.CenterVertically) { - // Camera flip button - IconButton( - onClick = { - cameraManager.switchCamera() - haptics.click() + if (!isGalleryPreview) { + // Camera flip button + IconButton( + onClick = { + cameraManager.switchCamera() + haptics.click() + } + ) { + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Switch Camera", + tint = Color.White + ) } - ) { - Icon( - imageVector = Icons.Default.FlipCameraAndroid, - contentDescription = "Switch Camera", - tint = Color.White - ) } // Toggle controls button @@ -336,66 +350,45 @@ fun CameraScreen( .systemGestureExclusion(), horizontalAlignment = Alignment.CenterHorizontally ) { - // Zoom presets (only show for back camera) - if (!isFrontCamera) { - ZoomControl( - currentZoom = zoomRatio, - minZoom = minZoom, - maxZoom = maxZoom, - onZoomSelected = { zoom -> - cameraManager.setZoom(zoom) - haptics.click() - } - ) - - Spacer(modifier = Modifier.height(24.dp)) - } - - // 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) + if (isGalleryPreview) { + // Gallery preview mode: Cancel | Apply + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(48.dp) ) { - Icon( - imageVector = Icons.Default.PhotoLibrary, - contentDescription = "Pick from gallery", - tint = Color.White, - modifier = Modifier.size(28.dp) - ) - } + // Cancel button + IconButton( + onClick = { + galleryBitmap?.recycle() + galleryBitmap = null + galleryImageUri = null + }, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0x80000000)) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } - // Capture button - CaptureButton( - isCapturing = isCapturing, - onClick = { - if (!isCapturing) { - isCapturing = true - haptics.heavyClick() - - scope.launch { - val imageCapture = cameraManager.imageCapture - if (imageCapture != null) { - val result = captureHandler.capturePhoto( - imageCapture = imageCapture, - executor = cameraManager.getExecutor(), + // Apply button + IconButton( + onClick = { + val uri = galleryImageUri ?: return@IconButton + if (!isCapturing) { + isCapturing = true + haptics.heavyClick() + scope.launch { + val result = captureHandler.processExistingImage( + imageUri = uri, blurParams = blurParams, - deviceRotation = currentRotation, - location = currentLocation, - isFrontCamera = isFrontCamera + location = currentLocation ) - when (result) { is SaveResult.Success -> { haptics.success() @@ -413,20 +406,121 @@ fun CameraScreen( showSaveError = null } } + galleryBitmap?.recycle() + galleryBitmap = null + galleryImageUri = null + isCapturing = false + } + } + }, + enabled = !isCapturing, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFFFB300)) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Apply effect", + tint = Color.Black, + modifier = Modifier.size(28.dp) + ) + } + } + } else { + // Camera mode: Zoom presets + Gallery | Capture | Spacer + // Zoom presets (only show for back camera) + if (!isFrontCamera) { + ZoomControl( + currentZoom = zoomRatio, + minZoom = minZoom, + maxZoom = maxZoom, + onZoomSelected = { zoom -> + cameraManager.setZoom(zoom) + haptics.click() + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + + // 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) + ) + } + + // Capture button + CaptureButton( + isCapturing = isCapturing, + onClick = { + if (!isCapturing) { + isCapturing = true + haptics.heavyClick() + + 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)) + // Spacer for visual symmetry with gallery button + Spacer(modifier = Modifier.size(52.dp)) + } } } - // Last captured photo thumbnail - LastPhotoThumbnail( + // Last captured photo thumbnail (hidden in gallery preview mode) + if (!isGalleryPreview) LastPhotoThumbnail( thumbnail = lastThumbnailBitmap, onTap = { lastSavedUri?.let { uri ->