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.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,57 +51,66 @@ 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 {
// Get rotation from ImageProxy (sensor orientation)
val imageRotation = imageProxy.imageInfo.rotationDegrees
// Convert ImageProxy to Bitmap
var bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close()
if (bitmap == null) {
continuation.resume(SaveResult.Error("Failed to convert image"))
continuation.resume(
SaveResult.Error("Failed to convert image") as Any
)
return
}
// Rotate bitmap to correct orientation
// Camera sensor is landscape, we need to rotate for portrait
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
// 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
)
processedBitmap.recycle()
continuation.resume(result)
}
continuation.resume(ProcessedCapture(processedBitmap))
} 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) {
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.
*/