diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/AndroidCameraProvider.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/AndroidCameraProvider.kt index 1949cd79..bb05b056 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/AndroidCameraProvider.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/AndroidCameraProvider.kt @@ -11,13 +11,14 @@ import androidx.core.content.ContextCompat import arrow.core.Either import arrow.core.left import arrow.core.right +import dev.stapler.stelekit.coroutines.PlatformDispatcher import dev.stapler.stelekit.error.DomainError import dev.stapler.stelekit.model.ImageSensorData import kotlinx.coroutines.CancellationException import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.File import java.util.UUID @@ -110,18 +111,28 @@ class AndroidCameraProvider( 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) - val sensorSnapshot = SensorModule.motionSensorProvider.sensorDataFlow.firstOrNull() - val capturedAt = System.currentTimeMillis() - - delay(400L) - - // 7. Take the photo — bridge ImageCapture callback to a suspend function - val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build() - val executor = Executors.newSingleThreadExecutor() - try { - // ponytail: 10s timeout — CameraX takePicture can hang silently on some devices - withTimeout(10_000L) { + // 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) { + // 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: + // SensorModule.motionSensorProvider is a shared, non-reference-counted + // singleton, and this method currently has no reachable production caller + // (see App.kt's unused executeCaptureAndImport) — calling stop/start here + // would race an already-open CameraViewfinderDialog's own sensing session. + // ponytail: best-effort snapshot only; wire start/stop here (with reference + // counting) if/when this path gets a real UI caller. + val sensorSnapshot = SensorModule.motionSensorProvider.snapshotSensorData() + val capturedAt = System.currentTimeMillis() + + delay(400L) + + // 7. Take the photo — bridge ImageCapture callback to a suspend function + val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build() + val executor = Executors.newSingleThreadExecutor() + try { suspendCancellableCoroutine { cont -> imageCapture.takePicture( outputOptions, @@ -138,61 +149,66 @@ class AndroidCameraProvider( ) cont.invokeOnCancellation { executor.shutdown() } } + } finally { + executor.shutdown() + cameraProvider.unbindAll() } - } finally { - executor.shutdown() - cameraProvider.unbindAll() - } - if (!outputFile.exists()) { - return DomainError.SensorError.CaptureFailed( - "CameraX onImageSaved fired but file missing: ${outputFile.absolutePath}" - ).left() - } + if (!outputFile.exists()) { + return@withTimeout DomainError.SensorError.CaptureFailed( + "CameraX onImageSaved fired but file missing: ${outputFile.absolutePath}" + ).left() + } - // 8. Fix EXIF orientation in-place and extract camera metadata - val fixResult = ExifOrientationFixer.fixOrientation(outputFile.absolutePath) - .fold( - ifLeft = { return it.left() }, + // 8. Fix EXIF orientation in-place and extract camera metadata. Off the + // calling dispatcher — full-res bitmap decode/rotate/encode must not block + // whichever thread capturePhoto() was invoked from. + val fixResult = withContext(PlatformDispatcher.IO) { + ExifOrientationFixer.fixOrientation(outputFile.absolutePath) + }.fold( + ifLeft = { return@withTimeout it.left() }, ifRight = { it }, ) - // 9. Merge EXIF camera metadata into motion sensor snapshot - val sensorData: ImageSensorData? = if (sensorSnapshot != null) { - sensorSnapshot.copy( - focalLengthMm = fixResult.focalLengthMm ?: sensorSnapshot.focalLengthMm, - focalLength35mmEq = fixResult.focalLength35mmEq - ?: sensorSnapshot.focalLength35mmEq, - cameraMake = fixResult.cameraMake ?: sensorSnapshot.cameraMake, - cameraModel = fixResult.cameraModel ?: sensorSnapshot.cameraModel, - ) - } else if (fixResult.focalLengthMm != null || fixResult.cameraMake != null) { - // No live sensor data — build from EXIF metadata alone - ImageSensorData( + // 9. Merge EXIF camera metadata into motion sensor snapshot + val sensorData: ImageSensorData? = if (sensorSnapshot != null) { + sensorSnapshot.copy( + focalLengthMm = fixResult.focalLengthMm ?: sensorSnapshot.focalLengthMm, + focalLength35mmEq = fixResult.focalLength35mmEq + ?: sensorSnapshot.focalLength35mmEq, + cameraMake = fixResult.cameraMake ?: sensorSnapshot.cameraMake, + cameraModel = fixResult.cameraModel ?: sensorSnapshot.cameraModel, + ) + } else if (fixResult.focalLengthMm != null || fixResult.cameraMake != null) { + // No live sensor data — build from EXIF metadata alone + ImageSensorData( + focalLengthMm = fixResult.focalLengthMm, + focalLength35mmEq = fixResult.focalLength35mmEq, + cameraMake = fixResult.cameraMake, + cameraModel = fixResult.cameraModel, + ) + } else { + null + } + + PlatformImageFile( + path = fixResult.outputPath, + mimeType = "image/jpeg", + capturedAtMs = capturedAt, focalLengthMm = fixResult.focalLengthMm, focalLength35mmEq = fixResult.focalLength35mmEq, cameraMake = fixResult.cameraMake, cameraModel = fixResult.cameraModel, - ) - } else { - null + sensorData = sensorData, + ).right() } - - PlatformImageFile( - path = fixResult.outputPath, - mimeType = "image/jpeg", - capturedAtMs = capturedAt, - focalLengthMm = fixResult.focalLengthMm, - focalLength35mmEq = fixResult.focalLength35mmEq, - cameraMake = fixResult.cameraMake, - cameraModel = fixResult.cameraModel, - sensorData = sensorData, - ).right() } catch (e: TimeoutCancellationException) { DomainError.SensorError.CaptureFailed("Camera capture timed out").left() } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { + // Throwable, not Exception — an OutOfMemoryError decoding a large frame must + // surface as a capture failure, not kill the process or hang. DomainError.SensorError.CaptureFailed( "CameraX capture failed: ${e.message ?: "unknown"}" ).left() diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/ExifOrientationFixer.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/ExifOrientationFixer.kt index da99a08f..76803598 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/ExifOrientationFixer.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/platform/sensor/ExifOrientationFixer.kt @@ -117,7 +117,10 @@ object ExifOrientationFixer { ).right() } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { + // Throwable, not Exception — an OutOfMemoryError decoding a large bitmap must + // surface as a capture failure, not silently kill the process (CLAUDE.md: + // uncaught Throwables in a coroutine kill the Android process). DomainError.SensorError.CaptureFailed( "ExifOrientationFixer failed for $inputPath: ${e.message ?: "unknown"}" ).left() diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/components/CameraViewfinderDialog.android.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/components/CameraViewfinderDialog.android.kt index 4dc78736..f9b8ff16 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/components/CameraViewfinderDialog.android.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/components/CameraViewfinderDialog.android.kt @@ -27,15 +27,18 @@ import androidx.compose.ui.window.Dialog 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.model.ImageSensorData import dev.stapler.stelekit.platform.sensor.ExifOrientationFixer import dev.stapler.stelekit.platform.sensor.PlatformImageFile import dev.stapler.stelekit.platform.sensor.SensorModule +import dev.stapler.stelekit.platform.sensor.snapshotSensorData import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.File import java.util.UUID @@ -53,6 +56,7 @@ actual fun CameraViewfinderDialog( val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() var isCapturing by remember { mutableStateOf(false) } + var captureJob by remember { mutableStateOf(null) } val previewView = remember(context) { PreviewView(context) } val imageCapture = remember { @@ -61,27 +65,56 @@ actual fun CameraViewfinderDialog( .build() } + val cancelCapture = { + captureJob?.cancel() + captureJob = null + isCapturing = false + onDismiss() + } + + // DisposableEffect is keyed on lifecycleOwner only (rebinding the camera on every + // recomposition would be wrong) — rememberUpdatedState keeps onError current without + // restarting the effect. + val currentOnError by rememberUpdatedState(onError) + DisposableEffect(lifecycleOwner) { + try { + SensorModule.motionSensorProvider.startSensing() + } catch (e: Throwable) { + currentOnError(e.message ?: "Failed to start sensors") + } val future = ProcessCameraProvider.getInstance(context) var provider: ProcessCameraProvider? = null future.addListener({ - provider = runCatching { future.get() }.getOrNull() ?: return@addListener + val result = runCatching { future.get() } + val obtained = result.getOrElse { + currentOnError(it.message ?: "Failed to start camera") + return@addListener + } + provider = obtained val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } - provider!!.unbindAll() - provider!!.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageCapture, - ) + try { + obtained.unbindAll() + obtained.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageCapture, + ) + } catch (e: Throwable) { + currentOnError(e.message ?: "Failed to bind camera") + } }, ContextCompat.getMainExecutor(context)) - onDispose { provider?.unbindAll() } + onDispose { + provider?.unbindAll() + SensorModule.motionSensorProvider.stopSensing() + } } Dialog( - onDismissRequest = { if (!isCapturing) onDismiss() }, + onDismissRequest = { cancelCapture() }, properties = DialogProperties(usePlatformDefaultWidth = false), ) { Box( @@ -99,7 +132,7 @@ actual fun CameraViewfinderDialog( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = { if (!isCapturing) onDismiss() }) { + IconButton(onClick = { cancelCapture() }) { Icon( Icons.Default.Close, contentDescription = "Cancel", @@ -116,7 +149,7 @@ actual fun CameraViewfinderDialog( .border(4.dp, Color.White.copy(alpha = 0.6f), CircleShape) .clickable(enabled = !isCapturing) { isCapturing = true - scope.launch { + captureJob = scope.launch { try { val result = takePhotoAndProcess(context, imageCapture) result.fold( @@ -125,6 +158,7 @@ actual fun CameraViewfinderDialog( ) } finally { isCapturing = false + captureJob = null } } }, @@ -153,11 +187,14 @@ private suspend fun takePhotoAndProcess( val capturesDir = File(context.cacheDir, "captures").also { it.mkdirs() } val outputFile = File(capturesDir, "${UUID.randomUUID()}.jpg") val capturedAt = System.currentTimeMillis() - val sensorSnapshot = SensorModule.motionSensorProvider.sensorDataFlow.firstOrNull() val outputOptions = ImageCapture.OutputFileOptions.Builder(outputFile).build() val executor = Executors.newSingleThreadExecutor() return try { + // 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. withTimeout(10_000L) { + val sensorSnapshot = SensorModule.motionSensorProvider.snapshotSensorData() suspendCancellableCoroutine { cont -> imageCapture.takePicture( outputOptions, @@ -173,34 +210,44 @@ private suspend fun takePhotoAndProcess( ) cont.invokeOnCancellation { executor.shutdown() } } - } - if (!outputFile.exists()) return Result.failure(Exception("Capture succeeded but file is missing")) - val fixResult = ExifOrientationFixer.fixOrientation(outputFile.absolutePath) - .fold( - ifLeft = { return Result.failure(Exception("Photo processing failed")) }, + if (!outputFile.exists()) { + return@withTimeout Result.failure( + Exception("Capture succeeded but file is missing") + ) + } + // Off the calling dispatcher — full-res bitmap decode/rotate/encode must not + // block the coroutine's current thread (Main, when launched from the UI). + val fixResult = withContext(PlatformDispatcher.IO) { + ExifOrientationFixer.fixOrientation(outputFile.absolutePath) + }.fold( + ifLeft = { return@withTimeout Result.failure(Exception("Photo processing failed")) }, ifRight = { it }, ) - val sensorData: ImageSensorData? = sensorSnapshot?.copy( - focalLengthMm = fixResult.focalLengthMm ?: sensorSnapshot.focalLengthMm, - focalLength35mmEq = fixResult.focalLength35mmEq ?: sensorSnapshot.focalLength35mmEq, - cameraMake = fixResult.cameraMake ?: sensorSnapshot.cameraMake, - cameraModel = fixResult.cameraModel ?: sensorSnapshot.cameraModel, - ) - Result.success(PlatformImageFile( - path = fixResult.outputPath, - mimeType = "image/jpeg", - capturedAtMs = capturedAt, - focalLengthMm = fixResult.focalLengthMm, - focalLength35mmEq = fixResult.focalLength35mmEq, - cameraMake = fixResult.cameraMake, - cameraModel = fixResult.cameraModel, - sensorData = sensorData, - )) + val sensorData: ImageSensorData? = sensorSnapshot?.copy( + focalLengthMm = fixResult.focalLengthMm ?: sensorSnapshot.focalLengthMm, + focalLength35mmEq = fixResult.focalLength35mmEq ?: sensorSnapshot.focalLength35mmEq, + cameraMake = fixResult.cameraMake ?: sensorSnapshot.cameraMake, + cameraModel = fixResult.cameraModel ?: sensorSnapshot.cameraModel, + ) + Result.success(PlatformImageFile( + path = fixResult.outputPath, + mimeType = "image/jpeg", + capturedAtMs = capturedAt, + focalLengthMm = fixResult.focalLengthMm, + focalLength35mmEq = fixResult.focalLength35mmEq, + cameraMake = fixResult.cameraMake, + cameraModel = fixResult.cameraModel, + sensorData = sensorData, + )) + } } catch (e: TimeoutCancellationException) { Result.failure(Exception("Camera timed out — try again")) } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { + // Throwable, not Exception — an OutOfMemoryError decoding a large frame must + // surface as a capture failure, not kill the process or hang. Pass e through + // directly (not re-wrapped) so the original type/stack trace/cause chain survives. Result.failure(e) } finally { executor.shutdown() diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProvider.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProvider.kt index 5832a290..996d3c68 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProvider.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProvider.kt @@ -3,6 +3,8 @@ package dev.stapler.stelekit.platform.sensor import dev.stapler.stelekit.model.ImageSensorData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull /** * Abstraction over platform motion and location sensors. @@ -61,6 +63,19 @@ interface MotionSensorProvider { fun stopSensing() } +/** + * Snapshot the most recent sensor reading, bounded by [timeoutMs]. + * + * A camera capture path must never hang waiting on sensor data. If [startSensing] was never + * called (or the provider is otherwise stalled), [sensorDataFlow] never emits and a bare + * `sensorDataFlow.firstOrNull()` would suspend forever. This returns `null` instead once + * [timeoutMs] elapses. Shared by both Android capture call sites + * ([dev.stapler.stelekit.ui.components.CameraViewfinderDialog] and [AndroidCameraProvider]) + * so the bound can't drift between the two. + */ +suspend fun MotionSensorProvider.snapshotSensorData(timeoutMs: Long = 500L): ImageSensorData? = + withTimeoutOrNull(timeoutMs) { sensorDataFlow.firstOrNull() } + /** * No-op motion sensor provider for JVM desktop and WASM targets. * diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt index 1eb14c96..f93cbec7 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/App.kt @@ -87,6 +87,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.plus import arrow.core.Either import dev.stapler.stelekit.sections.SectionState @@ -142,6 +143,17 @@ internal suspend fun executeCaptureAndImport( } } +/** + * Runs [importOperation] bounded by [timeoutMs], returning `null` on timeout instead of + * hanging. Used by [dev.stapler.stelekit.ui.components.CapturePreviewDialog]'s onSave handler + * so a stalled [ImageImportService.import] (blocked file IO, wedged DB write) can't leave the + * importing spinner stuck forever. + */ +internal suspend fun withImportTimeout( + timeoutMs: Long = 20_000L, + importOperation: suspend () -> T, +): T? = withTimeoutOrNull(timeoutMs) { importOperation() } + /** * Root Composable for the Logseq application. * Updated to use multi-graph support with GraphManager. @@ -1651,13 +1663,30 @@ private fun GraphContent( pendingCaptureBlockUuid = null return@launch } - val result = imageImportService?.import( - tempFile = file, - graphPath = graphPath, - pageUuid = dev.stapler.stelekit.model.PageUuid(pageUuid), - source = ImageSource.CAMERA, - insertToJournalPage = false, - ) + // ponytail: 20s timeout so a stalled save (blocked file + // IO, wedged DB write) can't leave isCaptureImporting + // stuck true forever. Residual risk: if the timeout + // fires after the sidecar/DB write but before the + // block-insert step, the cancelled import can leave an + // orphaned ImageAnnotation with no visible block — same + // class of gap as any hard cancellation mid-pipeline, + // not specific to this guard. Out of scope here; would + // need ImageImportService's own step recovery hardened. + val result = imageImportService?.let { service -> + withImportTimeout { + service.import( + tempFile = file, + graphPath = graphPath, + pageUuid = dev.stapler.stelekit.model.PageUuid(pageUuid), + source = ImageSource.CAMERA, + insertToJournalPage = false, + ) + } + } + if (imageImportService != null && result == null) { + graphContentLogger.warn("Camera image import timed out") + viewModel.sendSnackbar("Image save timed out — try again") + } result?.onLeft { err -> graphContentLogger.warn("Camera image import failed: ${err.message}") viewModel.sendSnackbar(err.toUiMessage()) diff --git a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProviderTest.kt b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProviderTest.kt index 1a45c399..fa6d8c32 100644 --- a/kmp/src/commonTest/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProviderTest.kt +++ b/kmp/src/commonTest/kotlin/dev/stapler/stelekit/platform/sensor/MotionSensorProviderTest.kt @@ -1,10 +1,13 @@ package dev.stapler.stelekit.platform.sensor import dev.stapler.stelekit.model.ImageSensorData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -97,4 +100,41 @@ class MotionSensorProviderTest { assertEquals(null, data.pitchDeg) assertEquals(null, data.rollDeg) } + + /** + * A fake mirroring the real pre-[MotionSensorProvider.startSensing] state: a + * [MutableSharedFlow] with no replay and nothing ever emitted into it, so any collector + * suspends forever without a bound. This is the exact hang [snapshotSensorData] guards + * against — see CameraViewfinderDialog.android.kt / AndroidCameraProvider.capturePhoto(). + */ + private class NeverEmittingMotionSensorProvider : MotionSensorProvider { + override val sensorDataFlow: Flow = MutableSharedFlow() + override fun startSensing() {} + override fun stopSensing() {} + } + + @Test + fun snapshotSensorData_shouldReturnNull_When_FlowNeverEmits() = runTest { + val provider = NeverEmittingMotionSensorProvider() + + val result = provider.snapshotSensorData(timeoutMs = 500L) + + assertNull(result, "must bound the snapshot instead of hanging when sensing was never started") + } + + @Test + fun snapshotSensorData_shouldReturnLatestReading_When_FlowHasEmitted() = runTest { + val data = ImageSensorData(bearingDeg = 12.0) + val flow = MutableSharedFlow(replay = 1) + flow.emit(data) + val provider = object : MotionSensorProvider { + override val sensorDataFlow: Flow = flow + override fun startSensing() {} + override fun stopSensing() {} + } + + val result = provider.snapshotSensorData(timeoutMs = 500L) + + assertEquals(data, result) + } } diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/CaptureAndImportTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/CaptureAndImportTest.kt index 32c2c01e..094b455f 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/CaptureAndImportTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/ui/CaptureAndImportTest.kt @@ -13,9 +13,11 @@ import dev.stapler.stelekit.platform.sensor.PlatformImageFile import dev.stapler.stelekit.repository.InMemoryBlockRepository import dev.stapler.stelekit.repository.InMemoryImageAnnotationRepository import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class CaptureAndImportTest { @@ -200,4 +202,22 @@ class CaptureAndImportTest { ) assertTrue(navigations.isEmpty(), "navigation not called when navigateAfterImport=false") } + + // ── withImportTimeout ─────────────────────────────────────────────────── + + @Test + fun `withImportTimeout returns null instead of hanging when the import operation never completes`() = + runBlocking { + val result = withImportTimeout>(timeoutMs = 200L) { + suspendCancellableCoroutine { /* never resumed — simulates a wedged save */ } + } + assertNull(result, "a stalled import must time out instead of hanging forever") + } + + @Test + fun `withImportTimeout returns the operation result when it completes in time`() = runBlocking { + val expected: Either = "ok".right() + val result = withImportTimeout(timeoutMs = 200L) { expected } + assertEquals(expected, result) + } }