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:
parent
7abb2ea5a0
commit
780a8ab167
3 changed files with 355 additions and 57 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue