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.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.

View file

@ -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"
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)

View file

@ -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<String?>(null) }
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 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 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,6 +351,31 @@ fun CameraScreen(
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,
@ -322,6 +399,9 @@ fun CameraScreen(
when (result) {
is SaveResult.Success -> {
haptics.success()
lastThumbnailBitmap?.recycle()
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
@ -339,7 +419,29 @@ fun CameraScreen(
}
}
)
// 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(
@ -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)
)
}
}
}