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,57 +51,66 @@ 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( imageCapture.takePicture(
executor, executor,
object : ImageCapture.OnImageCapturedCallback() { object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) { override fun onCaptureSuccess(imageProxy: ImageProxy) {
try { try {
// Get rotation from ImageProxy (sensor orientation)
val imageRotation = imageProxy.imageInfo.rotationDegrees val imageRotation = imageProxy.imageInfo.rotationDegrees
// Convert ImageProxy to Bitmap
var bitmap = imageProxyToBitmap(imageProxy) var bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close() imageProxy.close()
if (bitmap == null) { if (bitmap == null) {
continuation.resume(SaveResult.Error("Failed to convert image")) continuation.resume(
SaveResult.Error("Failed to convert image") as Any
)
return return
} }
// Rotate bitmap to correct orientation
// Camera sensor is landscape, we need to rotate for portrait
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera) bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
// Apply tilt-shift effect to the correctly oriented image
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams) val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
bitmap.recycle() bitmap.recycle()
// Save with EXIF orientation as NORMAL (bitmap is already rotated) continuation.resume(ProcessedCapture(processedBitmap))
kotlinx.coroutines.runBlocking {
val result = photoSaver.saveBitmap(
processedBitmap,
ExifInterface.ORIENTATION_NORMAL,
location
)
processedBitmap.recycle()
continuation.resume(result)
}
} catch (e: Exception) { } catch (e: Exception) {
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e)) continuation.resume(
SaveResult.Error("Capture failed: ${e.message}", e) as Any
)
} }
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
continuation.resume( continuation.resume(
SaveResult.Error("Capture failed: ${exception.message}", exception) SaveResult.Error(
"Capture failed: ${exception.message}", exception
) as Any
) )
} }
} }
) )
} }
// 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
}
/** /**
* Rotates a bitmap to the correct orientation. * Rotates a bitmap to the correct orientation.
*/ */