From 983bca36007be23095bcafaf040ebbcaa179757a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 5 Mar 2026 11:56:20 +0100 Subject: [PATCH] Type-safe capture pipeline and image dimension bounds - Replace unsafe as Any / as SaveResult casts with a sealed CaptureOutcome class for type-safe continuation handling - Catch SecurityException separately with permission-specific messages - Replace raw e.message with generic user-friendly error strings - Add inJustDecodeBounds pre-check in loadBitmapFromUri to downsample images exceeding 4096px, preventing OOM from huge gallery images Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 97 ++++++++++++------- 1 file changed, 60 insertions(+), 37 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 eea6c7f..f563487 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -34,17 +34,18 @@ class ImageCaptureHandler( companion object { private const val TAG = "ImageCaptureHandler" + /** Maximum decoded image dimension to prevent OOM from huge gallery images. */ + private const val MAX_IMAGE_DIMENSION = 4096 } /** - * Holds the processed bitmap ready for saving, produced inside the - * camera callback (synchronous CPU work) and consumed afterwards - * in the caller's coroutine context. + * Type-safe outcome of the camera capture callback. + * Eliminates unsafe `as Any` / `as SaveResult` casts. */ - private class ProcessedCapture( - val originalBitmap: Bitmap, - val processedBitmap: Bitmap - ) + private sealed class CaptureOutcome { + class Processed(val original: Bitmap, val processed: Bitmap) : CaptureOutcome() + class Failed(val result: SaveResult.Error) : CaptureOutcome() + } /** * Captures a photo and applies the tilt-shift effect. @@ -64,7 +65,7 @@ class ImageCaptureHandler( isFrontCamera: Boolean ): SaveResult { // Phase 1: capture and process the image synchronously in the callback - val captureResult = suspendCancellableCoroutine { continuation -> + val captureResult = suspendCancellableCoroutine { continuation -> imageCapture.takePicture( executor, object : ImageCapture.OnImageCapturedCallback() { @@ -78,7 +79,7 @@ class ImageCaptureHandler( if (currentBitmap == null) { continuation.resume( - SaveResult.Error("Failed to convert image") as Any + CaptureOutcome.Failed(SaveResult.Error("Failed to convert image")) ) return } @@ -86,25 +87,23 @@ class ImageCaptureHandler( currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) - // Keep originalBitmap alive — both are recycled after saving val original = currentBitmap currentBitmap = null - continuation.resume(ProcessedCapture(original, processedBitmap)) + continuation.resume(CaptureOutcome.Processed(original, processedBitmap)) } catch (e: Exception) { Log.e(TAG, "Image processing failed", e) currentBitmap?.recycle() continuation.resume( - SaveResult.Error("Capture failed: ${e.message}", e) as Any + CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e)) ) } } override fun onError(exception: ImageCaptureException) { + Log.e(TAG, "Image capture failed", exception) continuation.resume( - SaveResult.Error( - "Capture failed: ${exception.message}", exception - ) as Any + CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception)) ) } } @@ -112,28 +111,29 @@ class ImageCaptureHandler( } // Phase 2: save both original and processed to disk (suspend-safe) - if (captureResult is ProcessedCapture) { - return try { - 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 + return when (captureResult) { + is CaptureOutcome.Failed -> captureResult.result + is CaptureOutcome.Processed -> { + try { + val thumbnail = createThumbnail(captureResult.processed) + val result = photoSaver.saveBitmapPair( + original = captureResult.original, + processed = captureResult.processed, + orientation = ExifInterface.ORIENTATION_NORMAL, + location = location + ) + if (result is SaveResult.Success) { + result.copy(thumbnail = thumbnail) + } else { + thumbnail?.recycle() + result + } + } finally { + captureResult.original.recycle() + captureResult.processed.recycle() } - } finally { - captureResult.originalBitmap.recycle() - captureResult.processedBitmap.recycle() } } - - return captureResult as SaveResult } /** @@ -238,9 +238,12 @@ class ImageCaptureHandler( thumbnail?.recycle() result } + } catch (e: SecurityException) { + Log.e(TAG, "Permission denied while processing gallery image", e) + SaveResult.Error("Permission denied. Please grant access and try again.", e) } catch (e: Exception) { Log.e(TAG, "Gallery image processing failed", e) - SaveResult.Error("Processing failed: ${e.message}", e) + SaveResult.Error("Failed to process image. Please try again.", e) } finally { originalBitmap?.recycle() processedBitmap?.recycle() @@ -248,13 +251,33 @@ class ImageCaptureHandler( } /** - * Loads a bitmap from a content URI. + * Loads a bitmap from a content URI with dimension bounds checking + * to prevent OOM from extremely large images. */ private fun loadBitmapFromUri(uri: Uri): Bitmap? { return try { + // First pass: read dimensions without decoding pixels + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(uri)?.use { stream -> - BitmapFactory.decodeStream(stream) + BitmapFactory.decodeStream(stream, null, options) } + + // Calculate sample size to stay within MAX_IMAGE_DIMENSION + val maxDim = maxOf(options.outWidth, options.outHeight) + val sampleSize = if (maxDim > MAX_IMAGE_DIMENSION) { + var sample = 1 + while (maxDim / sample > MAX_IMAGE_DIMENSION) sample *= 2 + sample + } else 1 + + // Second pass: decode with sample size + val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize } + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, decodeOptions) + } + } catch (e: SecurityException) { + Log.e(TAG, "Permission denied loading bitmap from URI", e) + null } catch (e: Exception) { Log.e(TAG, "Failed to load bitmap from URI", e) null