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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-03 22:32:11 +01:00
commit 780a8ab167
3 changed files with 355 additions and 57 deletions

View file

@ -5,12 +5,15 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.location.Location import android.location.Location
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.PhotoSaver
@ -38,7 +41,10 @@ class ImageCaptureHandler(
* camera callback (synchronous CPU work) and consumed afterwards * camera callback (synchronous CPU work) and consumed afterwards
* in the caller's coroutine context. * 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. * Captures a photo and applies the tilt-shift effect.
@ -80,10 +86,11 @@ class ImageCaptureHandler(
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
currentBitmap.recycle() // Keep originalBitmap alive — both are recycled after saving
val original = currentBitmap
currentBitmap = null currentBitmap = null
continuation.resume(ProcessedCapture(processedBitmap)) continuation.resume(ProcessedCapture(original, processedBitmap))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Image processing failed", e) Log.e(TAG, "Image processing failed", e)
currentBitmap?.recycle() 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) { if (captureResult is ProcessedCapture) {
return try { return try {
photoSaver.saveBitmap( val thumbnail = createThumbnail(captureResult.processedBitmap)
captureResult.bitmap, val result = photoSaver.saveBitmapPair(
ExifInterface.ORIENTATION_NORMAL, original = captureResult.originalBitmap,
location processed = captureResult.processedBitmap,
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
) )
if (result is SaveResult.Success) {
result.copy(thumbnail = thumbnail)
} else {
thumbnail?.recycle()
result
}
} finally { } finally {
captureResult.bitmap.recycle() captureResult.originalBitmap.recycle()
captureResult.processedBitmap.recycle()
} }
} }
return captureResult as SaveResult 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. * Rotates a bitmap to the correct orientation.
*/ */
@ -158,6 +189,107 @@ class ImageCaptureHandler(
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) 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. * Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes. * Supports both linear and radial modes.

View file

@ -21,7 +21,12 @@ import java.util.Locale
* Result of a photo save operation. * Result of a photo save operation.
*/ */
sealed class SaveResult { 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() data class Error(val message: String, val exception: Exception? = null) : SaveResult()
} }
@ -44,10 +49,44 @@ class PhotoSaver(private val context: Context) {
orientation: Int, orientation: Int,
location: Location? location: Location?
): SaveResult = withContext(Dispatchers.IO) { ): 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 { val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName) put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") 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) put(MediaStore.Images.Media.IS_PENDING, 1)
} }
// Insert into MediaStore
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val uri = contentResolver.insert( val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues 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 -> contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, 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) writeExifToUri(uri, orientation, location)
// Mark as complete
contentValues.clear() contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null) contentResolver.update(uri, contentValues, null, null)
// Get the file path for display
val path = getPathFromUri(uri) val path = getPathFromUri(uri)
SaveResult.Success(uri, path) SaveResult.Success(uri, path)
} catch (e: Exception) { } catch (e: Exception) {
SaveResult.Error("Failed to save photo: ${e.message}", e) SaveResult.Error("Failed to save photo: ${e.message}", e)

View file

@ -1,15 +1,20 @@
package no.naiv.tiltshift.ui package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.location.Location import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.view.Surface import android.view.Surface
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@ -46,8 +55,11 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -97,9 +109,47 @@ fun CameraScreen(
var showSaveError by remember { mutableStateOf<String?>(null) } var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) } var showControls by remember { mutableStateOf(false) }
// Thumbnail state for last captured photo
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) } var currentLocation by remember { mutableStateOf<Location?>(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 zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState()
@ -154,6 +204,7 @@ fun CameraScreen(
onDispose { onDispose {
cameraManager.release() cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle()
} }
} }
@ -281,7 +332,8 @@ fun CameraScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = 24.dp), .padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Zoom presets (only show for back camera) // Zoom presets (only show for back camera)
@ -299,48 +351,98 @@ fun CameraScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
// Capture button // Gallery button | Capture button | Spacer for symmetry
CaptureButton( Row(
isCapturing = isCapturing, verticalAlignment = Alignment.CenterVertically,
onClick = { horizontalArrangement = Arrangement.spacedBy(24.dp)
if (!isCapturing) { ) {
isCapturing = true // Gallery picker button
haptics.heavyClick() 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 { // Capture button
val imageCapture = cameraManager.imageCapture CaptureButton(
if (imageCapture != null) { isCapturing = isCapturing,
val result = captureHandler.capturePhoto( onClick = {
imageCapture = imageCapture, if (!isCapturing) {
executor = cameraManager.getExecutor(), isCapturing = true
blurParams = blurParams, haptics.heavyClick()
deviceRotation = currentRotation,
location = currentLocation,
isFrontCamera = isFrontCamera
)
when (result) { scope.launch {
is SaveResult.Success -> { val imageCapture = cameraManager.imageCapture
haptics.success() if (imageCapture != null) {
showSaveSuccess = true val result = captureHandler.capturePhoto(
delay(1500) imageCapture = imageCapture,
showSaveSuccess = false executor = cameraManager.getExecutor(),
} blurParams = blurParams,
is SaveResult.Error -> { deviceRotation = currentRotation,
haptics.error() location = currentLocation,
showSaveError = result.message isFrontCamera = isFrontCamera
delay(2000) )
showSaveError = null
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 // Success indicator
AnimatedVisibility( AnimatedVisibility(
visible = showSaveSuccess, 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)
)
}
}
}