diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index 6ea0cc9835..8c39e970ff 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -73,6 +73,7 @@ jobs:
- 'infra/auth-logic/**'
- 'infra/matching/**'
- 'infra/backend-api/**'
+ - 'infra/camera/**'
feature1:
- 'feature/orchestrator/**'
- 'feature/client-api/**'
@@ -152,6 +153,7 @@ jobs:
infra:auth-logic
infra:matching
infra:backend-api
+ infra:camera
reportsId: infra2
feature-unit-tests1:
diff --git a/infra/camera/.gitignore b/infra/camera/.gitignore
new file mode 100644
index 0000000000..796b96d1c4
--- /dev/null
+++ b/infra/camera/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/infra/camera/build.gradle.kts b/infra/camera/build.gradle.kts
new file mode 100644
index 0000000000..0863f8c188
--- /dev/null
+++ b/infra/camera/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ id("simprints.infra")
+}
+
+android {
+ namespace = "com.simprints.infra.camera"
+}
+
+dependencies {
+ implementation(libs.androidX.cameraX.core)
+ implementation(libs.androidX.cameraX.lifecycle)
+ implementation(libs.androidX.cameraX.view)
+ implementation(libs.playServices.barcode)
+}
diff --git a/infra/camera/src/main/AndroidManifest.xml b/infra/camera/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..8072ee00db
--- /dev/null
+++ b/infra/camera/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt
new file mode 100644
index 0000000000..a336a7c08d
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt
@@ -0,0 +1,258 @@
+package com.simprints.infra.camera
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Rect
+import android.util.Size
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.lifecycle.awaitInstance
+import androidx.camera.view.PreviewView
+import androidx.lifecycle.LifecycleOwner
+import com.simprints.core.DispatcherBG
+import com.simprints.core.DispatcherMain
+import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
+import com.simprints.infra.camera.helpers.CameraFocusHelper
+import com.simprints.infra.camera.helpers.FrameEmissionHelper
+import com.simprints.infra.camera.usecase.NormalizeHighResBitmapToPreviewUseCase
+import com.simprints.infra.logging.LoggingConstants.CrashReportTag
+import com.simprints.infra.logging.Simber
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.withContext
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import javax.inject.Inject
+
+@ExcludedFromGeneratedTestCoverageReports(reason = "Camera API wrapper")
+class CameraFrameProvider @Inject internal constructor(
+ @ApplicationContext private val context: Context,
+ @DispatcherBG private val bgDispatcher: CoroutineDispatcher,
+ @DispatcherMain private val mainDispatcher: CoroutineDispatcher,
+ private val cameraFocusManagerFactory: CameraFocusHelper.Factory,
+ private val normalizeHighResBitmapToPreviewUseCase: NormalizeHighResBitmapToPreviewUseCase,
+) {
+ private var executor: ExecutorService = Executors.newSingleThreadExecutor()
+
+ val frames: Flow
+ field = MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ private var cameraProvider: ProcessCameraProvider? = null
+ private var imageCapture: ImageCapture? = null
+ private var imageAnalysis: ImageAnalysis? = null
+ private var camera: Camera? = null
+
+ private lateinit var previewRect: Rect
+ private lateinit var targetRect: Rect
+
+ private var previewSurface: PreviewView? = null
+ private val frameEmissionHelper = FrameEmissionHelper()
+
+ fun isInitialised() = camera != null
+
+ /**
+ * Initialise the camera and connect it to the provided UI elements.
+ *
+ * Call only after the preview and the targer has been laid out:
+ * ```
+ * binding.preview.awaitLayout()
+ * binding.targerOverlay.awaitLayout()
+ * ```
+ */
+
+ @ExcludedFromGeneratedTestCoverageReports(reason = "Camera API wrapper")
+ suspend fun initialiseCamera(
+ lifecycleOwner: LifecycleOwner,
+ previewView: PreviewView,
+ target: Rect? = null,
+ highResolution: Boolean = false,
+ onError: (Throwable) -> Unit = {},
+ ) = withContext(bgDispatcher) {
+ try {
+ // Caching to return with frames for post-processing and also to use for injection in future
+ previewRect = fullPreviewSizeRect(previewView)
+ targetRect = target ?: previewRect
+ } catch (e: Exception) {
+ Simber.e("Preview and target calculation failed", e, tag = CrashReportTag.CAMERA)
+ withContext(mainDispatcher) { onError(e) }
+ return@withContext
+ }
+
+ ensureExecutor()
+ previewSurface = previewView
+
+ frameEmissionHelper.configure(highResolution = highResolution)
+
+ val cameraSelector = DEFAULT_BACK_CAMERA
+ val resolutionSelector = ResolutionSelector
+ .Builder()
+ .setResolutionStrategy(
+ ResolutionStrategy(
+ Size(previewRect.width(), previewRect.height()),
+ ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
+ ),
+ ).build()
+
+ imageAnalysis = ImageAnalysis
+ .Builder()
+ .setResolutionSelector(resolutionSelector)
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .setOutputImageRotationEnabled(true)
+ .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
+ .build()
+
+ imageAnalysis?.setAnalyzer(executor) { proxy ->
+ proxy.use { frame ->
+ when {
+ frameEmissionHelper.shouldEmitAnalyserFrame() -> emitFrame(frame.toBitmap(), frame.imageInfo.rotationDegrees)
+ frameEmissionHelper.beginHighResolutionCapture() -> captureHighResolutionFrame()
+ }
+ }
+ }
+
+ val capture = ImageCapture
+ .Builder()
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
+ .build()
+ imageCapture = capture
+
+ withContext(mainDispatcher) {
+ val preview = Preview
+ .Builder()
+ .setResolutionSelector(resolutionSelector)
+ .build()
+ .also { it.surfaceProvider = previewView.surfaceProvider }
+
+ val provider = ProcessCameraProvider.awaitInstance(context).also {
+ cameraProvider = it
+ }
+
+ try {
+ provider.unbindAll()
+ camera = provider
+ .bindToLifecycle(
+ lifecycleOwner,
+ cameraSelector,
+ imageAnalysis,
+ capture,
+ preview,
+ ).also { boundCamera ->
+ with(cameraFocusManagerFactory.create(CrashReportTag.CAMERA)) {
+ setUpFocusOnTap(previewView, boundCamera)
+ setUpAutoFocus(previewView, boundCamera)
+ }
+ }
+ } catch (e: Exception) {
+ Simber.e("Camera binding failed", e, tag = CrashReportTag.CAMERA)
+ onError(e)
+ }
+ }
+ }
+
+ /**
+ * Toggle frame emission to save resources when processing of a
+ * single frame requires significant amount of time.
+ */
+ fun setFrameEmissionEnabled(enabled: Boolean) {
+ frameEmissionHelper.setFrameEmissionEnabled(enabled)
+ }
+
+ /**
+ * Toggle camera flash.
+ */
+ fun setTorchEnabled(enabled: Boolean) {
+ camera?.cameraControl?.enableTorch(enabled)
+ }
+
+ /**
+ * Clear all resources and stop the camera.
+ */
+ fun release() {
+ imageAnalysis?.clearAnalyzer()
+ imageAnalysis = null
+
+ cameraProvider?.unbindAll()
+ cameraProvider = null
+
+ previewSurface = null
+ imageCapture = null
+ camera = null
+ frameEmissionHelper.reset()
+
+ if (!executor.isShutdown) executor.shutdown()
+ }
+
+ private fun ensureExecutor() {
+ if (executor.isShutdown) {
+ executor = Executors.newSingleThreadExecutor()
+ }
+ }
+
+ private fun emitFrame(
+ bitmap: Bitmap,
+ rotation: Int,
+ ) {
+ frames.tryEmit(Frame(bitmap = bitmap, rotation = rotation, previewBounds = previewRect, targetBounds = targetRect))
+ }
+
+ private fun captureHighResolutionFrame() {
+ val capture = imageCapture
+ if (capture == null) {
+ frameEmissionHelper.cancelHighResolutionCapture()
+ return
+ }
+
+ try {
+ capture.takePicture(
+ executor,
+ @ExcludedFromGeneratedTestCoverageReports(reason = "Camera API wrapper")
+ object : ImageCapture.OnImageCapturedCallback() {
+ override fun onCaptureSuccess(imageProxy: ImageProxy) {
+ val shouldEmitFrame = frameEmissionHelper.completeHighResolutionCapture()
+ imageProxy.use { capturedFrame ->
+ val rotationDegrees = capturedFrame.imageInfo.rotationDegrees
+ val previewViewWidth = previewRect.width()
+ val previewViewHeight = previewRect.height()
+
+ if (shouldEmitFrame) {
+ emitFrame(
+ normalizeHighResBitmapToPreviewUseCase(
+ capturedFrame.toBitmap(),
+ rotationDegrees,
+ previewViewWidth,
+ previewViewHeight,
+ ),
+ rotationDegrees,
+ )
+ }
+ }
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ frameEmissionHelper.cancelHighResolutionCapture()
+ Simber.e("High-res frame capture failed", exception, tag = CrashReportTag.CAMERA)
+ }
+ },
+ )
+ } catch (e: Exception) {
+ frameEmissionHelper.cancelHighResolutionCapture()
+ Simber.e("High-res frame capture failed", e, tag = CrashReportTag.CAMERA)
+ }
+ }
+
+ private fun fullPreviewSizeRect(surface: PreviewView): Rect = Rect(0, 0, surface.width, surface.height)
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt b/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt
new file mode 100644
index 0000000000..1a7865e2f5
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/Frame.kt
@@ -0,0 +1,11 @@
+package com.simprints.infra.camera
+
+import android.graphics.Bitmap
+import android.graphics.Rect
+
+data class Frame(
+ val bitmap: Bitmap,
+ val rotation: Int,
+ val targetBounds: Rect,
+ val previewBounds: Rect,
+)
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/helpers/CameraFocusHelper.kt b/infra/camera/src/main/java/com/simprints/infra/camera/helpers/CameraFocusHelper.kt
new file mode 100644
index 0000000000..7c1298aa5b
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/helpers/CameraFocusHelper.kt
@@ -0,0 +1,126 @@
+package com.simprints.infra.camera.helpers
+
+import android.annotation.SuppressLint
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraControl
+import androidx.camera.core.CameraInfoUnavailableException
+import androidx.camera.core.FocusMeteringAction
+import androidx.camera.core.MeteringPoint
+import androidx.camera.core.MeteringPointFactory
+import androidx.camera.core.SurfaceOrientedMeteringPointFactory
+import androidx.camera.view.PreviewView
+import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
+import com.simprints.infra.logging.LoggingConstants
+import com.simprints.infra.logging.Simber
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.util.concurrent.TimeUnit
+
+@ExcludedFromGeneratedTestCoverageReports(
+ reason = "These are UI utilities for focus controls in the camera preview",
+)
+internal class CameraFocusHelper @AssistedInject constructor(
+ @Assisted private val crashReportTag: LoggingConstants.CrashReportTag,
+) {
+ @AssistedFactory
+ interface Factory {
+ fun create(crashReportTag: LoggingConstants.CrashReportTag): CameraFocusHelper
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ fun setUpFocusOnTap(
+ cameraPreview: PreviewView,
+ camera: Camera,
+ ) {
+ cameraPreview.afterMeasured {
+ it.setOnTouchListener(touchListener(camera.cameraControl))
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @ExcludedFromGeneratedTestCoverageReports("Generates inner class of excluded file")
+ private fun touchListener(cameraControl: CameraControl) = View.OnTouchListener { view, event ->
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> true
+
+ MotionEvent.ACTION_UP -> {
+ val focusPoint = getFocusOnTapPoint(view, event)
+
+ val focusAction = FocusMeteringAction
+ .Builder(
+ focusPoint,
+ FocusMeteringAction.FLAG_AF,
+ ).disableAutoCancel()
+ .build()
+
+ try {
+ cameraControl.startFocusAndMetering(focusAction)
+ } catch (e: CameraInfoUnavailableException) {
+ Simber.e("Cannot access camera", e, tag = crashReportTag)
+ }
+ true
+ }
+
+ else -> false
+ }
+ }
+
+ fun setUpAutoFocus(
+ cameraPreview: PreviewView,
+ camera: Camera,
+ ) {
+ cameraPreview.afterMeasured {
+ val focusPoint = getAutoFocusPoint(it)
+
+ val focusAction = FocusMeteringAction
+ .Builder(
+ focusPoint,
+ FocusMeteringAction.FLAG_AF,
+ ).setAutoCancelDuration(1, TimeUnit.SECONDS)
+ .build()
+
+ try {
+ camera.cameraControl.startFocusAndMetering(focusAction)
+ } catch (e: CameraInfoUnavailableException) {
+ Simber.e("Cannot access camera", e, tag = crashReportTag)
+ }
+ }
+ }
+
+ private inline fun PreviewView.afterMeasured(crossinline block: (previewView: PreviewView) -> Unit) {
+ viewTreeObserver.addOnGlobalLayoutListener(
+ @ExcludedFromGeneratedTestCoverageReports("Inner class of excluded file")
+ object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ if (measuredWidth > 0 && measuredHeight > 0) {
+ viewTreeObserver.removeOnGlobalLayoutListener(this)
+ block(this@afterMeasured)
+ }
+ }
+ },
+ )
+ }
+
+ private fun getFocusOnTapPoint(
+ view: View,
+ event: MotionEvent,
+ ): MeteringPoint = SurfaceOrientedMeteringPointFactory(
+ view.width.toFloat(),
+ view.height.toFloat(),
+ ).createPoint(event.x, event.y)
+
+ private fun getAutoFocusPoint(view: View): MeteringPoint {
+ val width = view.width.toFloat()
+ val height = view.height.toFloat()
+
+ val factory: MeteringPointFactory = SurfaceOrientedMeteringPointFactory(width, height)
+ val centreWidth = width / 2
+ val centreHeight = height / 2
+
+ return factory.createPoint(centreWidth, centreHeight)
+ }
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/helpers/FrameEmissionHelper.kt b/infra/camera/src/main/java/com/simprints/infra/camera/helpers/FrameEmissionHelper.kt
new file mode 100644
index 0000000000..89bb13190a
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/helpers/FrameEmissionHelper.kt
@@ -0,0 +1,72 @@
+package com.simprints.infra.camera.helpers
+
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Coordinates when camera frames may be emitted to consumers.
+ *
+ * This helper keeps track of two concerns:
+ * 1) **Caller readiness** - consumers can temporarily pause frame delivery while they are busy processing the previous frame.
+ * 2) **Capture mode** - in regular mode the frames are emitted directly;
+ * in high-resolution mode analyzer is used as triggers for a manual still capture.
+ *
+ * ### State model
+ * - [isFrameEmissionEnabled] controls whether any new frame should be delivered to downstream collectors.
+ * - [isHighResolutionEnabled] switches behavior between direct analyzer emission and manual still capture.
+ * - [isHighResolutionCaptureInProgress] prevents overlapping still captures in high-resolution mode.
+ *
+ * ### Typical high-resolution flow
+ * 1. Analyzer callback asks [beginHighResolutionCapture].
+ * 2. If it returns `true`, caller starts one manual image capture.
+ * 3. On success, caller invokes [completeHighResolutionCapture] to clear in-flight state and check whether
+ * the resulting frame should still be emitted.
+ * 4. On failure/cancellation, caller invokes [cancelHighResolutionCapture] to clear in-flight state.
+ */
+internal class FrameEmissionHelper {
+ private val isFrameEmissionEnabled = AtomicBoolean(true)
+ private val isHighResolutionEnabled = AtomicBoolean(false)
+ private val isHighResolutionCaptureInProgress = AtomicBoolean(false)
+
+ fun configure(highResolution: Boolean) {
+ isHighResolutionEnabled.set(highResolution)
+ isHighResolutionCaptureInProgress.set(false)
+ }
+
+ fun reset() {
+ isFrameEmissionEnabled.set(true)
+ isHighResolutionEnabled.set(false)
+ isHighResolutionCaptureInProgress.set(false)
+ }
+
+ fun setFrameEmissionEnabled(enabled: Boolean) {
+ isFrameEmissionEnabled.set(enabled)
+ }
+
+ /**
+ * Returns `true` only when direct analyzer frames should be emitted.
+ */
+ fun shouldEmitAnalyserFrame(): Boolean = isFrameEmissionEnabled.get() && !isHighResolutionEnabled.get()
+
+ /**
+ * Returns `true` when:
+ * - emission is currently enabled,
+ * - high-resolution mode is enabled,
+ * - there is no other capture already in progress.
+ */
+ fun beginHighResolutionCapture(): Boolean = isFrameEmissionEnabled.get() &&
+ isHighResolutionEnabled.get() &&
+ isHighResolutionCaptureInProgress.compareAndSet(false, true)
+
+ /**
+ * Marks the current high-resolution capture as complete and returns whether the captured frame should still be emitted.
+ * Emission can be disabled while capture is in flight; in that case this returns `false`.
+ */
+ fun completeHighResolutionCapture(): Boolean {
+ isHighResolutionCaptureInProgress.set(false)
+ return isFrameEmissionEnabled.get()
+ }
+
+ fun cancelHighResolutionCapture() {
+ isHighResolutionCaptureInProgress.set(false)
+ }
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/DetectQrCodeUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/DetectQrCodeUseCase.kt
new file mode 100644
index 0000000000..53bf06cf8a
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/DetectQrCodeUseCase.kt
@@ -0,0 +1,68 @@
+package com.simprints.infra.camera.postprocess
+
+import com.google.android.gms.tasks.Task
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import com.simprints.core.DispatcherBG
+import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
+import com.simprints.core.tools.extensions.resumeSafely
+import com.simprints.core.tools.extensions.resumeWithExceptionSafely
+import com.simprints.infra.camera.Frame
+import com.simprints.infra.logging.LoggingConstants
+import com.simprints.infra.logging.Simber
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+
+@ExcludedFromGeneratedTestCoverageReports(
+ reason = "Wrapper around QR detection provided by Play Services ML Kit",
+)
+class DetectQrCodeUseCase @AssistedInject constructor(
+ @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher,
+ @Assisted private val crashReportTag: LoggingConstants.CrashReportTag,
+ private val cropToTargetUseCase: FrameCropToTargetUseCase,
+) {
+ @AssistedFactory
+ interface Factory {
+ fun create(crashReportTag: LoggingConstants.CrashReportTag): DetectQrCodeUseCase
+ }
+
+ private val scanner = BarcodeScanning.getClient(
+ BarcodeScannerOptions
+ .Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ .build(),
+ )
+
+ suspend operator fun invoke(frame: Frame): String? = withContext(bgDispatcher) {
+ try {
+ detectInImage(InputImage.fromBitmap(cropToTargetUseCase(frame), frame.rotation))
+ } catch (t: Throwable) {
+ Simber.e("QR code detection failed", t, tag = crashReportTag)
+ null
+ }
+ }
+
+ private suspend fun detectInImage(image: InputImage): String? = try {
+ scanner
+ .process(image)
+ .awaitTask()
+ ?.firstOrNull { !it.rawValue.isNullOrEmpty() }
+ ?.rawValue
+ } catch (t: Throwable) {
+ Simber.e("QR code processing failed", t, tag = crashReportTag)
+ null
+ }
+
+ private suspend fun Task.awaitTask(): T = suspendCancellableCoroutine { continuation ->
+ this
+ .addOnSuccessListener(continuation::resumeSafely)
+ .addOnFailureListener { continuation.resumeWithExceptionSafely(it) }
+ .addOnCanceledListener { continuation.cancel() }
+ }
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCase.kt
new file mode 100644
index 0000000000..2e1accfcba
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCase.kt
@@ -0,0 +1,57 @@
+package com.simprints.infra.camera.postprocess
+
+import android.graphics.Bitmap
+import com.simprints.infra.camera.Frame
+import javax.inject.Inject
+import kotlin.math.max
+import kotlin.math.min
+
+class FrameCropToTargetUseCase @Inject constructor() {
+ operator fun invoke(frame: Frame): Bitmap {
+ if (frame.targetBounds.isEmpty) {
+ return frame.bitmap
+ }
+
+ val imageWidth = frame.bitmap.width
+ val imageHeight = frame.bitmap.height
+ val previewWidth = frame.previewBounds.width()
+ val previewHeight = frame.previewBounds.height()
+
+ // Adjust preview size to be fit-center with the image size
+ val widthRatio = imageWidth / previewWidth.toFloat()
+ val heightRatio = imageHeight / previewHeight.toFloat()
+
+ val scale = min(widthRatio, heightRatio)
+ val scaledWidth = (previewWidth * scale).toInt()
+ val scaledHeight = (previewHeight * scale).toInt()
+
+ // Find the offsets caused by fit-center scaling
+ val offsetX = (max(imageWidth, scaledWidth) - min(imageWidth, scaledWidth)) / 2
+ val offsetY = (max(imageHeight, scaledHeight) - min(imageHeight, scaledHeight)) / 2
+
+ // Clamp the target bounds to the preview size
+ val left = frame.targetBounds.left.coerceIn(0, previewWidth)
+ val right = frame.targetBounds.right.coerceIn(left, previewWidth)
+ val top = frame.targetBounds.top.coerceIn(0, previewHeight)
+ val bottom = frame.targetBounds.bottom.coerceIn(top, previewHeight)
+
+ // Scale the preview target to the new scale and offset
+ val cropLeft = offsetX + (left * scale).toInt()
+ val cropWidth = ((right - left) * scale).toInt()
+ val cropTop = offsetY + (top * scale).toInt()
+ val cropHeight = ((bottom - top) * scale).toInt()
+
+ // Cannot crop if target is empty
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ return frame.bitmap
+ }
+
+ return Bitmap.createBitmap(
+ frame.bitmap,
+ cropLeft,
+ cropTop,
+ cropWidth,
+ cropHeight,
+ )
+ }
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCase.kt
new file mode 100644
index 0000000000..aa67872df1
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCase.kt
@@ -0,0 +1,27 @@
+package com.simprints.infra.camera.usecase
+
+import android.graphics.Rect
+import android.view.View
+import javax.inject.Inject
+
+class GetBoundsRelativeToParentUseCase @Inject constructor() {
+ operator fun invoke(
+ parent: View,
+ child: View,
+ ): Rect {
+ val childLocation = IntArray(2)
+ val parentLocation = IntArray(2)
+ child.getLocationOnScreen(childLocation)
+ parent.getLocationOnScreen(parentLocation)
+
+ val offsetX = childLocation[0] - parentLocation[0]
+ val offsetY = childLocation[1] - parentLocation[1]
+
+ return Rect(
+ offsetX,
+ offsetY,
+ offsetX + child.width,
+ offsetY + child.height,
+ )
+ }
+}
diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt
new file mode 100644
index 0000000000..9624558bdb
--- /dev/null
+++ b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt
@@ -0,0 +1,64 @@
+package com.simprints.infra.camera.usecase
+
+import android.graphics.Bitmap
+import android.graphics.Matrix
+import androidx.core.graphics.scale
+import javax.inject.Inject
+
+internal class NormalizeHighResBitmapToPreviewUseCase @Inject constructor() {
+ /**
+ * Normalizes a camera capture [originalBitmap] to match the PreviewView's dimensions and aspect ratio.
+ *
+ * This method performs three transformations:
+ * 1. Rotation - Rotates the bitmap by the specified degrees if needed
+ * 2. Center cropping - Crops the bitmap to match PreviewView aspect ratio, keeping the center portion
+ * 3. Scaling - Scales the cropped bitmap to exactly match PreviewView dimensions
+ *
+ * The center cropping ensures that the normalized high-res bitmap has the same aspect ratio as what the user
+ * sees in the camera preview, making the target bounds spatially consistent with the preview overlay.
+ *
+ * @param originalBitmap the original camera capture bitmap
+ *
+ * @return a new bitmap with normalized dimensions and aspect ratio
+ */
+ operator fun invoke(
+ originalBitmap: Bitmap,
+ rotationDegrees: Int,
+ previewWidth: Int,
+ previewHeight: Int,
+ ): Bitmap {
+ // Rotate if necessary
+ val rotated = if (rotationDegrees != 0) {
+ val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
+ Bitmap.createBitmap(originalBitmap, 0, 0, originalBitmap.width, originalBitmap.height, matrix, true)
+ } else {
+ originalBitmap
+ }
+
+ // Center-crop to match PreviewView aspect ratio
+ val previewRatio = previewWidth.toFloat() / previewHeight
+ val inputRatio = rotated.width.toFloat() / rotated.height
+
+ val cropWidth: Int
+ val cropHeight: Int
+ val offsetX: Int
+ val offsetY: Int
+
+ if (inputRatio > previewRatio) {
+ cropHeight = rotated.height
+ cropWidth = (cropHeight * previewRatio).toInt()
+ offsetX = (rotated.width - cropWidth) / 2
+ offsetY = 0
+ } else {
+ cropWidth = rotated.width
+ cropHeight = (cropWidth / previewRatio).toInt()
+ offsetX = 0
+ offsetY = (rotated.height - cropHeight) / 2
+ }
+
+ val cropped = Bitmap.createBitmap(rotated, offsetX, offsetY, cropWidth, cropHeight)
+
+ // Scale to PreviewView size
+ return cropped.scale(previewWidth, previewHeight)
+ }
+}
diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/helpers/FrameEmissionHelperTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/helpers/FrameEmissionHelperTest.kt
new file mode 100644
index 0000000000..a7da930e35
--- /dev/null
+++ b/infra/camera/src/test/java/com/simprints/infra/camera/helpers/FrameEmissionHelperTest.kt
@@ -0,0 +1,69 @@
+package com.simprints.infra.camera.helpers
+
+import com.google.common.truth.Truth.*
+import org.junit.Before
+import org.junit.Test
+
+internal class FrameEmissionHelperTest {
+ private lateinit var controller: FrameEmissionHelper
+
+ @Before
+ fun setUp() {
+ controller = FrameEmissionHelper()
+ }
+
+ @Test
+ fun `should emit analyser frames when frame emission is enabled in standard mode`() {
+ controller.configure(highResolution = false)
+
+ assertThat(controller.shouldEmitAnalyserFrame()).isTrue()
+ assertThat(controller.beginHighResolutionCapture()).isFalse()
+ }
+
+ @Test
+ fun `should not emit frames or capture high resolution images when frame emission is disabled`() {
+ controller.configure(highResolution = false)
+ controller.setFrameEmissionEnabled(false)
+
+ assertThat(controller.shouldEmitAnalyserFrame()).isFalse()
+
+ controller.configure(highResolution = true)
+
+ assertThat(controller.beginHighResolutionCapture()).isFalse()
+ }
+
+ @Test
+ fun `should only allow one high resolution capture at a time`() {
+ controller.configure(highResolution = true)
+
+ assertThat(controller.beginHighResolutionCapture()).isTrue()
+ assertThat(controller.beginHighResolutionCapture()).isFalse()
+
+ controller.completeHighResolutionCapture()
+
+ assertThat(controller.beginHighResolutionCapture()).isTrue()
+ }
+
+ @Test
+ fun `should drop completed high resolution frame when emission is disabled during capture`() {
+ controller.configure(highResolution = true)
+
+ assertThat(controller.beginHighResolutionCapture()).isTrue()
+
+ controller.setFrameEmissionEnabled(false)
+
+ assertThat(controller.completeHighResolutionCapture()).isFalse()
+ assertThat(controller.beginHighResolutionCapture()).isFalse()
+ }
+
+ @Test
+ fun `should reset capture state when switching back to standard mode`() {
+ controller.configure(highResolution = true)
+ assertThat(controller.beginHighResolutionCapture()).isTrue()
+
+ controller.configure(highResolution = false)
+
+ assertThat(controller.shouldEmitAnalyserFrame()).isTrue()
+ assertThat(controller.beginHighResolutionCapture()).isFalse()
+ }
+}
diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt
new file mode 100644
index 0000000000..8ad0a7879f
--- /dev/null
+++ b/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt
@@ -0,0 +1,148 @@
+package com.simprints.infra.camera.postprocess
+
+import android.graphics.Bitmap
+import android.graphics.Rect
+import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.*
+import com.simprints.infra.camera.Frame
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+internal class FrameCropToTargetUseCaseTest {
+ lateinit var useCase: FrameCropToTargetUseCase
+
+ @Before
+ fun setUp() {
+ useCase = FrameCropToTargetUseCase()
+ }
+
+ @Test
+ fun `Skip cropping when target is empty`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1000, 2000),
+ targetBounds = Rect(200, 200, 200, 200), // i.e. empty
+ ),
+ )
+
+ // Cropped should be same as original
+ assertThat(cropped.width).isEqualTo(1000)
+ assertThat(cropped.height).isEqualTo(1000)
+ }
+
+ @Test
+ fun `Skip cropping when cutout rect empty after scaling`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1000, 1000),
+ targetBounds = Rect(1, 1, 2, 2), // Will scale to 0.1 pixel and round down
+ ),
+ )
+
+ // Cropped should be same as original
+ assertThat(cropped.width).isEqualTo(100)
+ assertThat(cropped.height).isEqualTo(100)
+ }
+
+ @Test
+ fun `Correctly crops when camera resolution is smaller than preview in portrait`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1000, 2000),
+ targetBounds = Rect(200, 200, 800, 800), // 600x600
+ ),
+ )
+
+ // Cropped should be still square and half the side length of original
+ assertThat(cropped.width).isEqualTo(300)
+ assertThat(cropped.height).isEqualTo(300)
+ }
+
+ @Test
+ fun `Correctly crops when camera resolution is smaller than preview in landscape`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 2000, 1000), // landscape
+ targetBounds = Rect(700, 200, 1300, 800), // 600x600
+ ),
+ )
+
+ // Cropped should be still square and half the side length of original
+ assertThat(cropped.width).isEqualTo(300)
+ assertThat(cropped.height).isEqualTo(300)
+ }
+
+ @Test
+ fun `Correctly crops when camera resolution is larger than preview in portrait`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(2000, 2000, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1000, 2000), // landscape
+ targetBounds = Rect(200, 200, 800, 800), // 600x600
+ ),
+ )
+
+ // Cropped should be still square
+ assertThat(cropped.width).isEqualTo(600)
+ assertThat(cropped.height).isEqualTo(600)
+ }
+
+ @Test
+ fun `Correctly crops when camera resolution is larger than preview in landscape`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(2000, 2000, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 2000, 1000), // landscape
+ targetBounds = Rect(700, 200, 1300, 800), // 600x600
+ ),
+ )
+
+ // Cropped should be still square and half the side length of original
+ assertThat(cropped.width).isEqualTo(600)
+ assertThat(cropped.height).isEqualTo(600)
+ }
+
+ @Test
+ fun `Correctly crops bitmap with valid cutout rectangle`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(1080, 1920, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1080, 1920),
+ targetBounds = Rect(200, 300, 800, 1500),
+ ),
+ )
+
+ // Cropped should be same size as target
+ assertThat(cropped.width).isEqualTo(600)
+ assertThat(cropped.height).isEqualTo(1200)
+ }
+
+ @Test
+ fun `Correctly clamps cutout rect that extends beyond bitmap bounds`() {
+ val cropped = useCase(
+ Frame(
+ bitmap = Bitmap.createBitmap(1080, 1920, Bitmap.Config.ARGB_8888),
+ rotation = 0,
+ previewBounds = Rect(0, 0, 1080, 1920),
+ targetBounds = Rect(-100, -200, 1280, 2020),
+ ),
+ )
+
+ // Cropped should be same size as preview
+ assertThat(cropped.width).isEqualTo(1080)
+ assertThat(cropped.height).isEqualTo(1920)
+ }
+}
diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCaseTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCaseTest.kt
new file mode 100644
index 0000000000..b89ec34c27
--- /dev/null
+++ b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/GetBoundsRelativeToParentUseCaseTest.kt
@@ -0,0 +1,153 @@
+package com.simprints.infra.camera.usecase
+
+import android.graphics.Rect
+import android.view.View
+import androidx.test.ext.junit.runners.*
+import com.google.common.truth.Truth.*
+import io.mockk.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+internal class GetBoundsRelativeToParentUseCaseTest {
+ private lateinit var useCase: GetBoundsRelativeToParentUseCase
+
+ companion object {
+ private const val PARENT_WIDTH_LARGE = 1000
+ private const val PARENT_HEIGHT_LARGE = 2000
+ private const val PARENT_WIDTH_MEDIUM = 800
+ private const val PARENT_HEIGHT_MEDIUM = 1200
+ private const val PARENT_WIDTH_SMALL = 500
+ private const val PARENT_HEIGHT_SMALL = 500
+
+ private const val CHILD_WIDTH_MEDIUM = 300
+ private const val CHILD_HEIGHT_MEDIUM = 400
+ private const val CHILD_WIDTH_SMALL = 250
+ private const val CHILD_HEIGHT_SMALL = 350
+ private const val CHILD_WIDTH_LARGE = 200
+ private const val CHILD_HEIGHT_LARGE = 200
+
+ private const val LOCATION_X_ORIGIN = 100
+ private const val LOCATION_Y_ORIGIN = 200
+ private const val LOCATION_X_OFFSET = 150
+ private const val LOCATION_Y_OFFSET = 250
+ private const val LOCATION_X_DIFFERENT = 50
+ private const val LOCATION_Y_DIFFERENT = 75
+ private const val LOCATION_X_FAR = 200
+ private const val LOCATION_Y_FAR = 300
+ private const val LOCATION_X_OUTSIDE = 50
+ private const val LOCATION_Y_OUTSIDE = 50
+ }
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this, relaxed = true)
+ useCase = GetBoundsRelativeToParentUseCase()
+ }
+
+ @Test
+ fun `calculates bounds when child is at parent origin`() {
+ val (parent, child) = setupViews(
+ parentLocation = intArrayOf(LOCATION_X_ORIGIN, LOCATION_Y_ORIGIN),
+ childLocation = intArrayOf(LOCATION_X_ORIGIN, LOCATION_Y_ORIGIN),
+ parentWidth = PARENT_WIDTH_LARGE,
+ parentHeight = PARENT_HEIGHT_LARGE,
+ childWidth = CHILD_WIDTH_MEDIUM,
+ childHeight = CHILD_HEIGHT_MEDIUM,
+ )
+
+ val result = useCase(parent, child)
+
+ val expectedRect = Rect(0, 0, CHILD_WIDTH_MEDIUM, CHILD_HEIGHT_MEDIUM)
+ assertThat(result).isEqualTo(expectedRect)
+ }
+
+ @Test
+ fun `calculates bounds when child is offset from parent`() {
+ val (parent, child) = setupViews(
+ parentLocation = intArrayOf(LOCATION_X_ORIGIN, LOCATION_Y_ORIGIN),
+ childLocation = intArrayOf(LOCATION_X_OFFSET, LOCATION_Y_OFFSET),
+ parentWidth = PARENT_WIDTH_LARGE,
+ parentHeight = PARENT_HEIGHT_LARGE,
+ childWidth = CHILD_WIDTH_MEDIUM,
+ childHeight = CHILD_HEIGHT_MEDIUM,
+ )
+
+ val result = useCase(parent, child)
+
+ val offsetX = LOCATION_X_OFFSET - LOCATION_X_ORIGIN
+ val offsetY = LOCATION_Y_OFFSET - LOCATION_Y_ORIGIN
+ val expectedRect = Rect(offsetX, offsetY, offsetX + CHILD_WIDTH_MEDIUM, offsetY + CHILD_HEIGHT_MEDIUM)
+ assertThat(result).isEqualTo(expectedRect)
+ }
+
+ @Test
+ fun `calculates bounds when parent and child have different screen positions`() {
+ val (parent, child) = setupViews(
+ parentLocation = intArrayOf(LOCATION_X_DIFFERENT, LOCATION_Y_DIFFERENT),
+ childLocation = intArrayOf(LOCATION_X_FAR, LOCATION_Y_FAR),
+ parentWidth = PARENT_WIDTH_MEDIUM,
+ parentHeight = PARENT_HEIGHT_MEDIUM,
+ childWidth = CHILD_WIDTH_SMALL,
+ childHeight = CHILD_HEIGHT_SMALL,
+ )
+
+ val result = useCase(parent, child)
+
+ val offsetX = LOCATION_X_FAR - LOCATION_X_DIFFERENT
+ val offsetY = LOCATION_Y_FAR - LOCATION_Y_DIFFERENT
+ val expectedRect = Rect(offsetX, offsetY, offsetX + CHILD_WIDTH_SMALL, offsetY + CHILD_HEIGHT_SMALL)
+ assertThat(result).isEqualTo(expectedRect)
+ }
+
+ @Test
+ fun `calculates bounds when child is partially outside parent bounds`() {
+ val (parent, child) = setupViews(
+ parentLocation = intArrayOf(LOCATION_X_ORIGIN, LOCATION_Y_ORIGIN),
+ childLocation = intArrayOf(LOCATION_X_OUTSIDE, LOCATION_Y_OUTSIDE),
+ parentWidth = PARENT_WIDTH_SMALL,
+ parentHeight = PARENT_HEIGHT_SMALL,
+ childWidth = CHILD_WIDTH_LARGE,
+ childHeight = CHILD_HEIGHT_LARGE,
+ )
+
+ val result = useCase(parent, child)
+
+ val offsetX = LOCATION_X_OUTSIDE - LOCATION_X_ORIGIN
+ val offsetY = LOCATION_Y_OUTSIDE - LOCATION_Y_ORIGIN
+ val expectedRect = Rect(offsetX, offsetY, offsetX + CHILD_WIDTH_LARGE, offsetY + CHILD_HEIGHT_LARGE)
+ assertThat(result).isEqualTo(expectedRect)
+ }
+
+ private fun setupViews(
+ parentLocation: IntArray,
+ childLocation: IntArray,
+ parentWidth: Int,
+ parentHeight: Int,
+ childWidth: Int,
+ childHeight: Int,
+ ): Pair {
+ val parent = mockk()
+ val child = mockk()
+
+ mockkStatic("android.view.View")
+ every { parent.getLocationOnScreen(any()) } answers {
+ val location = firstArg()
+ location[0] = parentLocation[0]
+ location[1] = parentLocation[1]
+ }
+ every { child.getLocationOnScreen(any()) } answers {
+ val location = firstArg()
+ location[0] = childLocation[0]
+ location[1] = childLocation[1]
+ }
+
+ every { parent.width } returns parentWidth
+ every { parent.height } returns parentHeight
+ every { child.width } returns childWidth
+ every { child.height } returns childHeight
+
+ return Pair(parent, child)
+ }
+}
diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt
new file mode 100644
index 0000000000..11805f0411
--- /dev/null
+++ b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt
@@ -0,0 +1,100 @@
+package com.simprints.infra.camera.usecase
+
+import android.graphics.Bitmap
+import android.graphics.Matrix
+import androidx.core.graphics.scale
+import com.google.common.truth.*
+import io.mockk.*
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+internal class NormalizeHighResBitmapToPreviewUseCaseTest {
+ @MockK
+ private lateinit var inputBitmap: Bitmap
+
+ @MockK
+ private lateinit var rotatedBitmap: Bitmap
+
+ @MockK
+ private lateinit var croppedBitmap: Bitmap
+
+ @MockK
+ private lateinit var scaledBitmap: Bitmap
+
+ private lateinit var useCase: NormalizeHighResBitmapToPreviewUseCase
+
+ private val inputWidth = 1920
+ private val inputHeight = 1080
+ private val previewWidth = 800
+ private val previewHeight = 600
+ private val rotationDegrees = 90
+
+ @Before
+ fun setUp() {
+ MockKAnnotations.init(this, relaxed = true)
+
+ mockkStatic(Bitmap::class)
+ mockkStatic("androidx.core.graphics.BitmapKt")
+ mockkConstructor(Matrix::class)
+
+ every { inputBitmap.width } returns inputWidth
+ every { inputBitmap.height } returns inputHeight
+ every { rotatedBitmap.width } returns inputHeight
+ every { rotatedBitmap.height } returns inputWidth
+ every { anyConstructed().postRotate(any()) } returns true
+ every { croppedBitmap.scale(any(), any()) } returns scaledBitmap
+
+ useCase = NormalizeHighResBitmapToPreviewUseCase()
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(Bitmap::class)
+ unmockkStatic("androidx.core.graphics.BitmapKt")
+ unmockkConstructor(Matrix::class)
+ }
+
+ @Test
+ fun `returns original bitmap when no rotation is needed`() = runTest {
+ every {
+ Bitmap.createBitmap(inputBitmap, any(), any(), any(), any())
+ } returns croppedBitmap
+
+ val result = useCase(inputBitmap, 0, previewWidth, previewHeight)
+
+ Truth.assertThat(result).isEqualTo(scaledBitmap)
+ verify(exactly = 0) {
+ Bitmap.createBitmap(
+ inputBitmap,
+ 0,
+ 0,
+ inputWidth,
+ inputHeight,
+ any(),
+ true,
+ )
+ }
+ verify { Bitmap.createBitmap(inputBitmap, any(), any(), any(), any()) }
+ verify { croppedBitmap.scale(previewWidth, previewHeight) }
+ }
+
+ @Test
+ fun `returns scaled bitmap after rotation cropping and scaling`() = runTest {
+ every {
+ Bitmap.createBitmap(inputBitmap, 0, 0, inputWidth, inputHeight, any(), true)
+ } returns rotatedBitmap
+
+ every {
+ Bitmap.createBitmap(rotatedBitmap, any(), any(), any(), any())
+ } returns croppedBitmap
+
+ val result = useCase(inputBitmap, rotationDegrees, previewWidth, previewHeight)
+
+ Truth.assertThat(result).isEqualTo(scaledBitmap)
+ verify { Bitmap.createBitmap(inputBitmap, 0, 0, inputWidth, inputHeight, any(), true) }
+ verify { croppedBitmap.scale(previewWidth, previewHeight) }
+ }
+}
diff --git a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt
index ee671f6fd5..ed9932d681 100644
--- a/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt
+++ b/infra/logging/src/main/java/com/simprints/infra/logging/LoggingConstants.kt
@@ -54,6 +54,7 @@ object LoggingConstants {
APPLICATION,
COMMCARE_SYNC,
MULTI_FACTOR_ID,
+ CAMERA,
}
// Tags eligible for Firebase Analytics logging
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2b2c5a8fa5..aaa2672da9 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -151,6 +151,7 @@ include(
":infra:sync",
":infra:event-sync",
":infra:backend-api",
+ ":infra:camera",
)
// Test modules
include(