Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,46 +75,54 @@ class AndroidCameraProvider(
}

return try {
// 2. Obtain ProcessCameraProvider — bridge ListenableFuture to suspend
val cameraProvider: ProcessCameraProvider = suspendCancellableCoroutine { cont ->
val future = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
future.addListener(
{
if (cont.isActive) {
runCatching { future.get() }
.onSuccess { cont.resume(it) }
.onFailure { cont.resumeWithException(it) }
}
},
executor,
// ponytail: 10s timeout bounds the whole pipeline — provider acquisition, bind,
// shutter, and EXIF fix — not just takePicture(). Previously getInstance()/
// bindToLifecycle() sat outside this block: a wedged ListenableFuture or a
// hardware bind that never resolves would hang capturePhoto() indefinitely, the
// same failure class this timeout exists to eliminate, just one stage earlier.
// Note: cancellation is cooperative (Kotlin can only interrupt at a suspension
// point) — the EXIF fix's synchronous BitmapFactory decode/rotate/encode and a
// truly HAL-wedged takePicture() cannot be preempted mid-call. This timeout gives
// up and surfaces an error to the caller within ~10s in that case, but the
// underlying thread/camera binding may remain occupied until the native call
// eventually returns. Known residual risk, not preemptible from Kotlin.
withTimeout(10_000L) {
// 2. Obtain ProcessCameraProvider — bridge ListenableFuture to suspend
val cameraProvider: ProcessCameraProvider = suspendCancellableCoroutine { cont ->
val future = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
future.addListener(
{
if (cont.isActive) {
runCatching { future.get() }
.onSuccess { cont.resume(it) }
.onFailure { cont.resumeWithException(it) }
}
},
executor,
)
cont.invokeOnCancellation { future.cancel(true) }
}

// 3. Build ImageCapture use case
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()

// 4. Bind to ProcessLifecycleOwner — no Activity reference required.
// Use fully-qualified class to avoid the Glance bindToLifecycle extension clash.
val lifecycleOwner = androidx.lifecycle.ProcessLifecycleOwner.get()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
imageCapture,
)
cont.invokeOnCancellation { future.cancel(true) }
}

// 3. Build ImageCapture use case
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()

// 4. Bind to ProcessLifecycleOwner — no Activity reference required.
// Use fully-qualified class to avoid the Glance bindToLifecycle extension clash.
val lifecycleOwner = androidx.lifecycle.ProcessLifecycleOwner.get()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
imageCapture,
)

// 5. Prepare output file: cacheDir/captures/<uuid>.jpg
val capturesDir = File(context.cacheDir, "captures").also { it.mkdirs() }
val outputFile = File(capturesDir, "${UUID.randomUUID()}.jpg")

// ponytail: 10s timeout bounds the whole pipeline (shutter + EXIF fix), not just
// takePicture() — EXIF processing on a large/rotated JPEG used to run unbounded
// after this timeout had already elapsed.
withTimeout(10_000L) {
// 5. Prepare output file: cacheDir/captures/<uuid>.jpg
val capturesDir = File(context.cacheDir, "captures").also { it.mkdirs() }
val outputFile = File(capturesDir, "${UUID.randomUUID()}.jpg")

// 6. Snapshot sensor data at shutter time (Story 8.1.5). Timeout-guarded so a
// provider whose sensorDataFlow never emits cannot hang the capture
// indefinitely. This class does not call startSensing()/stopSensing() itself:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import dev.stapler.stelekit.coroutines.PlatformDispatcher
import dev.stapler.stelekit.logging.Logger
import dev.stapler.stelekit.model.ImageSensorData
import dev.stapler.stelekit.platform.sensor.ExifOrientationFixer
import dev.stapler.stelekit.platform.sensor.PlatformImageFile
Expand All @@ -46,6 +47,8 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

private val logger = Logger("CameraCapture")

@Composable
actual fun CameraViewfinderDialog(
onCapture: (PlatformImageFile) -> Unit,
Expand Down Expand Up @@ -81,14 +84,20 @@ actual fun CameraViewfinderDialog(
try {
SensorModule.motionSensorProvider.startSensing()
} catch (e: Throwable) {
currentOnError(e.message ?: "Failed to start sensors")
// Raw exception text (CameraX/hardware internals, or an OutOfMemoryError's
// diagnostic message) must not reach the user-facing snackbar — log it and
// surface a generic message instead, matching the sanitization already applied
// to every other camera/import error path via DomainError.toUiMessage().
logger.warn("Failed to start sensors: ${e.message}", e)
currentOnError("Failed to start sensors")
}
val future = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
future.addListener({
val result = runCatching { future.get() }
val obtained = result.getOrElse {
currentOnError(it.message ?: "Failed to start camera")
logger.warn("Failed to start camera: ${it.message}", it)
currentOnError("Failed to start camera")
return@addListener
}
provider = obtained
Expand All @@ -104,7 +113,8 @@ actual fun CameraViewfinderDialog(
imageCapture,
)
} catch (e: Throwable) {
currentOnError(e.message ?: "Failed to bind camera")
logger.warn("Failed to bind camera: ${e.message}", e)
currentOnError("Failed to bind camera")
}
}, ContextCompat.getMainExecutor(context))
onDispose {
Expand Down Expand Up @@ -154,8 +164,26 @@ actual fun CameraViewfinderDialog(
val result = takePhotoAndProcess(context, imageCapture)
result.fold(
onSuccess = { file -> onCapture(file) },
onFailure = { err -> onError(err.message ?: "Capture failed"); onDismiss() },
onFailure = { err ->
// Raw exception text (which may be an
// OutOfMemoryError's diagnostic message, now that
// takePhotoAndProcess catches Throwable) must not
// reach the user-facing snackbar unsanitized.
logger.warn("Capture failed: ${err.message}", err)
onError("Capture failed")
onDismiss()
},
)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Guards the caller-supplied onCapture/onError/onDismiss
// callbacks: an uncaught Throwable here would otherwise
// propagate on this scope (a plain
// rememberCoroutineScope() with no
// CoroutineExceptionHandler) and can kill the Android
// process.
logger.warn("Capture callback crashed: ${e.message}", e)
} finally {
isCapturing = false
captureJob = null
Expand Down Expand Up @@ -193,6 +221,12 @@ private suspend fun takePhotoAndProcess(
// Bounds the whole pipeline (sensor snapshot + shutter + EXIF fix), not just the
// shutter call — EXIF processing on a large/rotated JPEG used to run unbounded
// after this timeout had already elapsed.
// Note: cancellation is cooperative (Kotlin can only interrupt at a suspension
// point) — the EXIF fix's synchronous BitmapFactory decode/rotate/encode and a
// truly HAL-wedged takePicture() cannot be preempted mid-call. This timeout gives
// up and surfaces an error to the caller within ~10s in that case, but the
// underlying thread/camera binding may remain occupied until the native call
// eventually returns. Known residual risk, not preemptible from Kotlin.
withTimeout(10_000L) {
val sensorSnapshot = SensorModule.motionSensorProvider.snapshotSensorData()
suspendCancellableCoroutine { cont ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package dev.stapler.stelekit.platform.sensor
import android.graphics.Bitmap
import androidx.exifinterface.media.ExifInterface
import arrow.core.Either
import dev.stapler.stelekit.coroutines.PlatformDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
Expand All @@ -13,6 +16,7 @@ import java.io.File
import java.io.FileOutputStream
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertNull

/**
Expand Down Expand Up @@ -285,4 +289,43 @@ class ExifOrientationFixerTest {
val result = ExifOrientationFixer.fixOrientation("/tmp/does_not_exist_abc123.jpg")
assertIs<Either.Left<dev.stapler.stelekit.error.DomainError.SensorError.CaptureFailed>>(result)
}

// ── Dispatcher hop (hang-fix regression) ──────────────────────────────────

/**
* Regression test for the reviewed hang: [ExifOrientationFixer.fixOrientation] is a
* synchronous full-res bitmap decode/rotate/encode. Both production call sites
* (CameraViewfinderDialog.android.kt's takePhotoAndProcess and
* AndroidCameraProvider.capturePhoto()) wrap it in
* `withContext(PlatformDispatcher.IO) { ... }` so it never runs on the calling
* (Main) coroutine.
*
* Scope note: this test exercises the same `withContext(PlatformDispatcher.IO) { ... }`
* pattern the production call sites use, but does not invoke either production call
* site directly (CameraX/`ImageCapture` aren't cheaply fakeable under Robolectric). It
* proves the pattern itself moves execution off the calling thread; it does NOT catch a
* regression where a production call site stops using this pattern. Full coverage of
* that would require exercising `takePhotoAndProcess`/`capturePhoto()` end-to-end — see
* validation.md's documented residual-risk note on why that's not currently automated.
*/
@Test
fun `fixOrientation wrapped in PlatformDispatcher IO runs off the calling thread`() = runBlocking {
val callingThread = Thread.currentThread()
val input = tempFolder.newFile("dispatcher_hop.jpg")
writeJpegWithOrientation(input, ExifInterface.ORIENTATION_ROTATE_90)

var executingThread: Thread? = null
val result = withContext(PlatformDispatcher.IO) {
executingThread = Thread.currentThread()
ExifOrientationFixer.fixOrientation(input.absolutePath)
}

assertIs<Either.Right<ExifOrientationFixer.FixResult>>(result)
assertNotEquals(
callingThread, executingThread,
"fixOrientation must run off the calling thread when dispatched via " +
"PlatformDispatcher.IO — a full-res decode/rotate/encode on the calling " +
"(Main) thread is the dispatcher-hop hang this test guards against",
)
}
}
Loading
Loading