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(