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:
parent
cc133072fc
commit
983bca3600
1 changed files with 60 additions and 37 deletions
|
|
@ -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,28 +111,29 @@ 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 -> {
|
||||||
val result = photoSaver.saveBitmapPair(
|
try {
|
||||||
original = captureResult.originalBitmap,
|
val thumbnail = createThumbnail(captureResult.processed)
|
||||||
processed = captureResult.processedBitmap,
|
val result = photoSaver.saveBitmapPair(
|
||||||
orientation = ExifInterface.ORIENTATION_NORMAL,
|
original = captureResult.original,
|
||||||
location = location
|
processed = captureResult.processed,
|
||||||
)
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
||||||
if (result is SaveResult.Success) {
|
location = location
|
||||||
result.copy(thumbnail = thumbnail)
|
)
|
||||||
} else {
|
if (result is SaveResult.Success) {
|
||||||
thumbnail?.recycle()
|
result.copy(thumbnail = thumbnail)
|
||||||
result
|
} 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()
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue