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 @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Job?>(null) }

val previewView = remember(context) { PreviewView(context) }
val imageCapture = remember {
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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(
Expand All @@ -125,6 +158,7 @@ actual fun CameraViewfinderDialog(
)
} finally {
isCapturing = false
captureJob = null
}
}
},
Expand Down Expand Up @@ -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,
Expand All @@ -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<PlatformImageFile>(
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<PlatformImageFile>(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()
Expand Down
Loading
Loading