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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 11:56:20 +01:00
commit 983bca3600

View file

@ -34,17 +34,18 @@ class ImageCaptureHandler(
companion object { companion object {
private const val TAG = "ImageCaptureHandler" 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 * Type-safe outcome of the camera capture callback.
* camera callback (synchronous CPU work) and consumed afterwards * Eliminates unsafe `as Any` / `as SaveResult` casts.
* in the caller's coroutine context.
*/ */
private class ProcessedCapture( private sealed class CaptureOutcome {
val originalBitmap: Bitmap, class Processed(val original: Bitmap, val processed: Bitmap) : CaptureOutcome()
val processedBitmap: Bitmap class Failed(val result: SaveResult.Error) : CaptureOutcome()
) }
/** /**
* Captures a photo and applies the tilt-shift effect. * Captures a photo and applies the tilt-shift effect.
@ -64,7 +65,7 @@ class ImageCaptureHandler(
isFrontCamera: Boolean isFrontCamera: Boolean
): SaveResult { ): SaveResult {
// Phase 1: capture and process the image synchronously in the callback // Phase 1: capture and process the image synchronously in the callback
val captureResult = suspendCancellableCoroutine { continuation -> val captureResult = suspendCancellableCoroutine<CaptureOutcome> { continuation ->
imageCapture.takePicture( imageCapture.takePicture(
executor, executor,
object : ImageCapture.OnImageCapturedCallback() { object : ImageCapture.OnImageCapturedCallback() {
@ -78,7 +79,7 @@ class ImageCaptureHandler(
if (currentBitmap == null) { if (currentBitmap == null) {
continuation.resume( continuation.resume(
SaveResult.Error("Failed to convert image") as Any CaptureOutcome.Failed(SaveResult.Error("Failed to convert image"))
) )
return return
} }
@ -86,25 +87,23 @@ class ImageCaptureHandler(
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera) currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams) val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
// Keep originalBitmap alive — both are recycled after saving
val original = currentBitmap val original = currentBitmap
currentBitmap = null currentBitmap = null
continuation.resume(ProcessedCapture(original, processedBitmap)) continuation.resume(CaptureOutcome.Processed(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()
continuation.resume( 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) { override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "Image capture failed", exception)
continuation.resume( continuation.resume(
SaveResult.Error( CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception))
"Capture failed: ${exception.message}", exception
) as Any
) )
} }
} }
@ -112,12 +111,14 @@ class ImageCaptureHandler(
} }
// Phase 2: save both original and processed to disk (suspend-safe) // Phase 2: save both original and processed to disk (suspend-safe)
if (captureResult is ProcessedCapture) { return when (captureResult) {
return try { is CaptureOutcome.Failed -> captureResult.result
val thumbnail = createThumbnail(captureResult.processedBitmap) is CaptureOutcome.Processed -> {
try {
val thumbnail = createThumbnail(captureResult.processed)
val result = photoSaver.saveBitmapPair( val result = photoSaver.saveBitmapPair(
original = captureResult.originalBitmap, original = captureResult.original,
processed = captureResult.processedBitmap, processed = captureResult.processed,
orientation = ExifInterface.ORIENTATION_NORMAL, orientation = ExifInterface.ORIENTATION_NORMAL,
location = location location = location
) )
@ -128,12 +129,11 @@ class ImageCaptureHandler(
result result
} }
} finally { } finally {
captureResult.originalBitmap.recycle() captureResult.original.recycle()
captureResult.processedBitmap.recycle() captureResult.processed.recycle()
}
} }
} }
return captureResult as SaveResult
} }
/** /**
@ -238,9 +238,12 @@ class ImageCaptureHandler(
thumbnail?.recycle() thumbnail?.recycle()
result 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) { } catch (e: Exception) {
Log.e(TAG, "Gallery image processing failed", e) 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 { } finally {
originalBitmap?.recycle() originalBitmap?.recycle()
processedBitmap?.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? { private fun loadBitmapFromUri(uri: Uri): Bitmap? {
return try { return try {
// First pass: read dimensions without decoding pixels
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { stream -> 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) { } catch (e: Exception) {
Log.e(TAG, "Failed to load bitmap from URI", e) Log.e(TAG, "Failed to load bitmap from URI", e)
null null