Fix runBlocking deadlock in ImageCaptureHandler

runBlocking on the camera callback thread could deadlock when
saveBitmap() needed the main thread. Split capturePhoto() into two
phases: synchronous CPU work (decode/rotate/effect) inside the
suspendCancellableCoroutine callback, and suspend-safe saveBitmap()
after the continuation resumes in coroutine context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-02-27 15:19:41 +01:00
commit f53d6f0b1b

View file

@ -3,9 +3,7 @@ package no.naiv.tiltshift.camera
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint
import android.location.Location import android.location.Location
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
@ -30,8 +28,21 @@ class ImageCaptureHandler(
private val photoSaver: PhotoSaver private val photoSaver: PhotoSaver
) { ) {
/**
* Holds the processed bitmap ready for saving, produced inside the
* camera callback (synchronous CPU work) and consumed afterwards
* in the caller's coroutine context.
*/
private class ProcessedCapture(val bitmap: Bitmap)
/** /**
* Captures a photo and applies the tilt-shift effect. * Captures a photo and applies the tilt-shift effect.
*
* Phase 1 (inside suspendCancellableCoroutine / camera callback):
* decode rotate apply effect (synchronous CPU work only)
*
* Phase 2 (after continuation resumes, back in coroutine context):
* save bitmap via PhotoSaver (suspend-safe)
*/ */
suspend fun capturePhoto( suspend fun capturePhoto(
imageCapture: ImageCapture, imageCapture: ImageCapture,
@ -40,55 +51,64 @@ class ImageCaptureHandler(
deviceRotation: Int, deviceRotation: Int,
location: Location?, location: Location?,
isFrontCamera: Boolean isFrontCamera: Boolean
): SaveResult = suspendCancellableCoroutine { continuation -> ): SaveResult {
// Phase 1: capture and process the image synchronously in the callback
val captureResult = suspendCancellableCoroutine { continuation ->
imageCapture.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
try {
val imageRotation = imageProxy.imageInfo.rotationDegrees
imageCapture.takePicture( var bitmap = imageProxyToBitmap(imageProxy)
executor, imageProxy.close()
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
try {
// Get rotation from ImageProxy (sensor orientation)
val imageRotation = imageProxy.imageInfo.rotationDegrees
// Convert ImageProxy to Bitmap if (bitmap == null) {
var bitmap = imageProxyToBitmap(imageProxy) continuation.resume(
imageProxy.close() SaveResult.Error("Failed to convert image") as Any
)
return
}
if (bitmap == null) { bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
continuation.resume(SaveResult.Error("Failed to convert image"))
return
}
// Rotate bitmap to correct orientation val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
// Camera sensor is landscape, we need to rotate for portrait bitmap.recycle()
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
// Apply tilt-shift effect to the correctly oriented image continuation.resume(ProcessedCapture(processedBitmap))
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) } catch (e: Exception) {
bitmap.recycle() continuation.resume(
SaveResult.Error("Capture failed: ${e.message}", e) as Any
// Save with EXIF orientation as NORMAL (bitmap is already rotated)
kotlinx.coroutines.runBlocking {
val result = photoSaver.saveBitmap(
processedBitmap,
ExifInterface.ORIENTATION_NORMAL,
location
) )
processedBitmap.recycle()
continuation.resume(result)
} }
} catch (e: Exception) { }
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e))
override fun onError(exception: ImageCaptureException) {
continuation.resume(
SaveResult.Error(
"Capture failed: ${exception.message}", exception
) as Any
)
} }
} }
)
}
override fun onError(exception: ImageCaptureException) { // Phase 2: save to disk in the caller's coroutine context (suspend-safe)
continuation.resume( if (captureResult is ProcessedCapture) {
SaveResult.Error("Capture failed: ${exception.message}", exception) return try {
) photoSaver.saveBitmap(
} captureResult.bitmap,
ExifInterface.ORIENTATION_NORMAL,
location
)
} finally {
captureResult.bitmap.recycle()
} }
) }
return captureResult as SaveResult
} }
/** /**