From f53d6f0b1bfcbdfd96e336a6f4a41ed329350bed Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 27 Feb 2026 15:19:41 +0100 Subject: [PATCH] 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 --- .../tiltshift/camera/ImageCaptureHandler.kt | 100 +++++++++++------- 1 file changed, 60 insertions(+), 40 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 aaa6279..b7a8d6b 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -3,9 +3,7 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas import android.graphics.Matrix -import android.graphics.Paint import android.location.Location import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException @@ -30,8 +28,21 @@ class ImageCaptureHandler( 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. + * + * 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( imageCapture: ImageCapture, @@ -40,55 +51,64 @@ class ImageCaptureHandler( deviceRotation: Int, location: Location?, 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( - executor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(imageProxy: ImageProxy) { - try { - // Get rotation from ImageProxy (sensor orientation) - val imageRotation = imageProxy.imageInfo.rotationDegrees + var bitmap = imageProxyToBitmap(imageProxy) + imageProxy.close() - // Convert ImageProxy to Bitmap - var bitmap = imageProxyToBitmap(imageProxy) - imageProxy.close() + if (bitmap == null) { + continuation.resume( + SaveResult.Error("Failed to convert image") as Any + ) + return + } - if (bitmap == null) { - continuation.resume(SaveResult.Error("Failed to convert image")) - return - } + bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) - // Rotate bitmap to correct orientation - // Camera sensor is landscape, we need to rotate for portrait - bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) + val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) + bitmap.recycle() - // Apply tilt-shift effect to the correctly oriented image - val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) - bitmap.recycle() - - // Save with EXIF orientation as NORMAL (bitmap is already rotated) - kotlinx.coroutines.runBlocking { - val result = photoSaver.saveBitmap( - processedBitmap, - ExifInterface.ORIENTATION_NORMAL, - location + continuation.resume(ProcessedCapture(processedBitmap)) + } catch (e: Exception) { + continuation.resume( + SaveResult.Error("Capture failed: ${e.message}", e) as Any ) - 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) { - continuation.resume( - SaveResult.Error("Capture failed: ${exception.message}", exception) - ) - } + // Phase 2: save to disk in the caller's coroutine context (suspend-safe) + if (captureResult is ProcessedCapture) { + return try { + photoSaver.saveBitmap( + captureResult.bitmap, + ExifInterface.ORIENTATION_NORMAL, + location + ) + } finally { + captureResult.bitmap.recycle() } - ) + } + + return captureResult as SaveResult } /**