diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt
index 313d41f88b..d97441a0aa 100644
--- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt
+++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt
@@ -37,7 +37,6 @@ import com.simprints.feature.externalcredential.screens.search.model.ScannedCred
import com.simprints.infra.camera.CameraFrameProvider
import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID
import com.simprints.infra.logging.Simber
-import com.simprints.infra.uibase.camera.qrscan.CameraFocusManager
import com.simprints.infra.uibase.navigation.navigateSafely
import com.simprints.infra.uibase.view.applySystemBarInsets
import com.simprints.infra.uibase.view.awaitLayout
@@ -93,9 +92,6 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
@Inject
lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory
- @Inject
- lateinit var cameraFocusManagerFactory: CameraFocusManager.Factory
-
@Inject
@DispatcherBG
lateinit var bgDispatcher: CoroutineDispatcher
diff --git a/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml b/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml
index d1c3d88ffb..5b81ea335b 100644
--- a/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml
+++ b/feature/external-credential/src/main/res/layout-land/fragment_external_credential_scan_ocr.xml
@@ -12,7 +12,7 @@
android:scaleType="fitCenter"
tools:background="@tools:sample/backgrounds/scenic" />
-
-
-
-
-
-
-
-
-
-
@@ -86,9 +86,9 @@
android:orientation="vertical"
android:padding="@dimen/padding_default"
android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintBottom_toBottomOf="parent"
tools:visibility="visible">
+ android:src="@drawable/ic_checked_green_large"
+ tools:alpha="1" />
-
-
-
-
-
-
-
@@ -83,8 +83,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
- android:paddingBottom="@dimen/padding_default"
android:paddingHorizontal="@dimen/padding_default"
+ android:paddingBottom="@dimen/padding_default"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -99,11 +99,11 @@
android:id="@+id/iconScanComplete"
android:layout_width="36dp"
android:layout_height="36dp"
- android:alpha="0"
- tools:alpha="1"
android:layout_gravity="center"
+ android:alpha="0"
android:importantForAccessibility="no"
- android:src="@drawable/ic_checked_green_large"/>
+ android:src="@drawable/ic_checked_green_large"
+ tools:alpha="1" />
-
-
-
-
- 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 : 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/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt
deleted file mode 100644
index 79335dc118..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan
-
-import android.content.Context
-import androidx.camera.core.AspectRatio
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.ImageAnalysis
-import androidx.camera.core.Preview
-import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.camera.view.PreviewView
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.LifecycleOwner
-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 dagger.hilt.android.qualifiers.ApplicationContext
-import java.util.concurrent.Executors
-
-@ExcludedFromGeneratedTestCoverageReports(
- reason = "This is an injectable wrapper around cameraX and ML kit APIs. There is no business logic.",
-)
-class CameraHelper @AssistedInject constructor(
- @ApplicationContext private val context: Context,
- @Assisted private val crashReportTag: LoggingConstants.CrashReportTag,
- private val cameraFocusManagerFactory: CameraFocusManager.Factory,
-) {
- private val cameraFocusManager by lazy { cameraFocusManagerFactory.create(crashReportTag) }
- private var cameraProvider: ProcessCameraProvider? = null
-
- @AssistedFactory
- interface Factory {
- fun create(crashReportTag: LoggingConstants.CrashReportTag): CameraHelper
- }
-
- fun startCamera(
- lifecycleOwner: LifecycleOwner,
- cameraPreview: PreviewView,
- qrAnalyser: QrCodeAnalyzer,
- initializationErrorListener: CameraInitializationErrorListener,
- ) {
- val providerFuture = ProcessCameraProvider.getInstance(context)
- providerFuture.addListener(
- {
- val provider = providerFuture.get().also { provider ->
- cameraProvider = provider
- provider.unbindAll()
- }
-
- // Check if the back camera is available
- if (provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA).not()) {
- initializationErrorListener.onCameraError()
- return@addListener
- }
- val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
- val analyzer = buildAnalyser(qrAnalyser)
- val preview = buildPreview(cameraPreview)
-
- try {
- provider
- .bindToLifecycle(
- lifecycleOwner,
- cameraSelector,
- analyzer,
- preview,
- ).let {
- with(cameraFocusManager) {
- setUpFocusOnTap(cameraPreview, it)
- setUpAutoFocus(cameraPreview, it)
- }
- }
- } catch (e: Exception) {
- Simber.i("Camera is already in use by another process", e, tag = crashReportTag)
- initializationErrorListener.onCameraError()
- }
- },
- ContextCompat.getMainExecutor(context),
- )
- }
-
- fun stopCamera() {
- cameraProvider?.unbindAll()
- cameraProvider = null
- }
-
- private fun buildAnalyser(qrAnalyser: QrCodeAnalyzer) = ImageAnalysis
- .Builder()
- .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
- .setTargetAspectRatio(AspectRatio.RATIO_16_9)
- .build()
- .apply { setAnalyzer(Executors.newSingleThreadExecutor(), qrAnalyser) }
-
- private fun buildPreview(previewView: PreviewView): Preview = Preview
- .Builder()
- .setTargetAspectRatio(AspectRatio.RATIO_16_9)
- .build()
- .apply { setSurfaceProvider(previewView.surfaceProvider) }
-}
-
-fun interface CameraInitializationErrorListener {
- fun onCameraError()
-}
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzer.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzer.kt
deleted file mode 100644
index 39f8ae661c..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzer.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan
-
-import android.graphics.Rect
-import androidx.camera.core.ExperimentalGetImage
-import androidx.camera.core.ImageAnalysis
-import androidx.camera.core.ImageProxy
-import com.google.mlkit.vision.common.InputImage
-import com.simprints.core.DispatcherBG
-import com.simprints.infra.logging.LoggingConstants
-import com.simprints.infra.logging.Simber
-import com.simprints.infra.uibase.camera.qrscan.usecase.CropBitmapAreaForDetectionUseCase
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.buffer
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.runBlocking
-
-
-class QrCodeAnalyzer @AssistedInject constructor(
- private val qrCodeDetectorFactory: QrCodeDetector.Factory,
- @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher,
- @Assisted private val cropConfig: CropConfig?,
- @Assisted private val crashReportTag: LoggingConstants.CrashReportTag,
- private val cropBitmapAreaForDetectionUseCase: CropBitmapAreaForDetectionUseCase
-) : ImageAnalysis.Analyzer {
-
-
- @AssistedFactory
- interface Factory {
- fun create(cropConfig: CropConfig?, crashReportTag: LoggingConstants.CrashReportTag): QrCodeAnalyzer
- }
-
- /**
- * Specifies what rectangle to crop from the image. This configuration is used in the image analysis - instead of analyzing the entire
- * image, we are limiting the detection area to [rect]. The coordinates in [rect] are relative to the full image size, whose dimensions
- * are given by [rootViewWidth] and [rootViewHeight]. Orientation of the image should be provided from the configuration constants.
- *
- * @param rect area to crop and analyze relative to the full image
- * @param orientation orientation of the parent image. Should be provided from the configuration constants
- * @param rootViewWidth width of the parent image
- * @param rootViewHeight height of the parent image
- */
- data class CropConfig(
- val rect: Rect,
- val orientation: Int,
- val rootViewWidth: Int,
- val rootViewHeight: Int,
- )
-
- private val qrCodeDetector by lazy { qrCodeDetectorFactory.create(crashReportTag) }
- private val _scannedCode = MutableStateFlow(null)
- val scannedCode: Flow = _scannedCode
- .filterNotNull()
- .buffer(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
-
- @ExperimentalGetImage
- override fun analyze(imageProxy: ImageProxy) {
- imageProxy.use {
- it.image?.let { mediaImage ->
- runBlocking(bgDispatcher) {
- try {
- val rotationDegrees = imageProxy.imageInfo.rotationDegrees
- when (cropConfig) {
- null -> {
- val image = RawImage(mediaImage, rotationDegrees)
- qrCodeDetector.detectInImage(image)?.let(_scannedCode::tryEmit)
- }
-
- else -> {
- val bitmap = cropBitmapAreaForDetectionUseCase(imageProxy.toBitmap(), cropConfig)
- val image = InputImage.fromBitmap(bitmap, 0)
- qrCodeDetector.detectInImage(image)?.let(_scannedCode::tryEmit)
- }
- }
-
- } catch (t: Throwable) {
- Simber.e("QR code detection failed", t, tag = crashReportTag)
- }
- }
- }
- }
- }
-}
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeDetector.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeDetector.kt
deleted file mode 100644
index 946a83a866..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeDetector.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan
-
-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.ExcludedFromGeneratedTestCoverageReports
-import com.simprints.core.tools.extensions.resumeSafely
-import com.simprints.core.tools.extensions.resumeWithExceptionSafely
-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.suspendCancellableCoroutine
-
-@ExcludedFromGeneratedTestCoverageReports(
- reason = "This is just an injectable wrapper around MLKit barcode analyzer",
-)
-class QrCodeDetector @AssistedInject constructor(
- @Assisted private val crashReportTag: LoggingConstants.CrashReportTag,
-) {
- @AssistedFactory
- interface Factory {
- fun create(crashReportTag: LoggingConstants.CrashReportTag): QrCodeDetector
- }
-
- private val scanner = BarcodeScanning.getClient(
- BarcodeScannerOptions
- .Builder()
- .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
- .build(),
- )
-
- suspend fun detectInImage(rawImage: RawImage): String? =
- detectInImage(InputImage.fromMediaImage(rawImage.image, rawImage.rotationDegrees))
-
- 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/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/RawImage.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/RawImage.kt
deleted file mode 100644
index a72956ab04..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/RawImage.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan
-
-import android.media.Image
-
-data class RawImage(
- val image: Image,
- val rotationDegrees: Int,
-)
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCase.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCase.kt
deleted file mode 100644
index bc556b6604..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCase.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.graphics.Bitmap
-import android.graphics.Rect
-import com.simprints.infra.uibase.camera.qrscan.QrCodeAnalyzer
-import javax.inject.Inject
-
-class CropBitmapAreaForDetectionUseCase @Inject constructor(
- private val rotateToPortraitIfNeededUseCase: RotateToPortraitIfNeededUseCase,
- private val mapCropRectToImageSpaceUseCase: MapCropRectToImageSpaceUseCase,
- private val cropBitmapToRectUseCase: CropBitmapToRectUseCase
-) {
- /**
- * Takes a [bitmap] image, and crops it to the area specified in [cropConfig]
- */
- operator fun invoke(bitmap: Bitmap, cropConfig: QrCodeAnalyzer.CropConfig): Bitmap {
- val rotatedBitmap: Bitmap = rotateToPortraitIfNeededUseCase(bitmap, cropConfig.orientation)
- val crop: Rect = mapCropRectToImageSpaceUseCase(
- cropRectInRoot = cropConfig.rect,
- rootWidth = cropConfig.rootViewWidth,
- rootHeight = cropConfig.rootViewHeight,
- imageWidth = rotatedBitmap.width,
- imageHeight = rotatedBitmap.height
- )
- val isLeftOutOfBounds = crop.left < 0
- val isTopOutOfBounds = crop.top < 0
- val isRightOutOfBounds = crop.right > rotatedBitmap.width
- val isBottomOutOfBounds = crop.bottom > rotatedBitmap.height
-
- // a safety check to ensure that crop area is fully inside the rotated bitmap bounds
- return if (isLeftOutOfBounds || isTopOutOfBounds || isRightOutOfBounds || isBottomOutOfBounds) {
- bitmap
- } else {
- cropBitmapToRectUseCase(rotatedBitmap, crop)
- }
- }
-}
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCase.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCase.kt
deleted file mode 100644
index 56a51a17a7..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCase.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.graphics.Bitmap
-import android.graphics.Rect
-import javax.inject.Inject
-
-class CropBitmapToRectUseCase @Inject constructor() {
- /**
- * Crops given [source] bitmap to the specified [cropRect]
- *
- * @param source bitmap to crop
- * @param cropRect target crop rectangle
- * @return a new [Bitmap] representing the cropped region
- */
- operator fun invoke(source: Bitmap, cropRect: Rect): Bitmap {
- val left = cropRect.left.coerceIn(0, source.width)
- val top = cropRect.top.coerceIn(0, source.height)
- val width = cropRect.width().coerceAtMost(source.width - left)
- val height = cropRect.height().coerceAtMost(source.height - top)
-
- return Bitmap.createBitmap(source, left, top, width, height)
- }
-}
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/MapCropRectToImageSpaceUseCase.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/MapCropRectToImageSpaceUseCase.kt
deleted file mode 100644
index 7180118613..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/MapCropRectToImageSpaceUseCase.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.graphics.Rect
-import javax.inject.Inject
-
-class MapCropRectToImageSpaceUseCase @Inject constructor() {
- /**
- * Maps a crop rectangle defined in the root view's coordinate space into the corresponding rectangle in the image's coordinate space.
- * This is required when the crop area is selected on a view (UI) with different dimensions than the underlying image being processed.
- *
- * @param cropRectInRoot the crop rectangle coordinates in the root view space
- * @param rootWidth the width of the root view
- * @param rootHeight the height of the root view
- * @param imageWidth the width of the image
- * @param imageHeight the height of the image
- * @return a [Rect] representing the crop area in the image's coordinate space
- */
- operator fun invoke(
- cropRectInRoot: Rect,
- rootWidth: Int,
- rootHeight: Int,
- imageWidth: Int,
- imageHeight: Int,
- ): Rect {
- val scaleX = imageWidth.toFloat() / rootWidth
- val scaleY = imageHeight.toFloat() / rootHeight
-
- return Rect(
- (cropRectInRoot.left * scaleX).toInt(),
- (cropRectInRoot.top * scaleY).toInt(),
- (cropRectInRoot.right * scaleX).toInt(),
- (cropRectInRoot.bottom * scaleY).toInt()
- )
- }
-}
diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCase.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCase.kt
deleted file mode 100644
index 88c5605179..0000000000
--- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCase.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.content.res.Configuration
-import android.graphics.Bitmap
-import android.graphics.Matrix
-import javax.inject.Inject
-
-class RotateToPortraitIfNeededUseCase @Inject constructor() {
- /**
- * Rotates given [bitmap] by 90 degrees if it is in landscape mode. Otherwise, returns the original [bitmap] without modification.
- * Images from video feed are usually in landscape mode, and we want to be sure that the calculation are always happening in the same
- * space.
- *
- * @param bitmap image to potentially rotate
- * @param orientation the current device orientation from [Configuration] (portrait/landscape)
- * @return a rotated bitmap if rotation was needed, otherwise the original bitmap
- */
- operator fun invoke(bitmap: Bitmap, orientation: Int): Bitmap {
- val isLandscape = bitmap.width > bitmap.height
- val isPortraitOrientation = orientation == Configuration.ORIENTATION_PORTRAIT
-
- return if (isLandscape && isPortraitOrientation) {
- val matrix = Matrix().apply { postRotate(90f) }
- Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
- } else {
- bitmap
- }
- }
-}
diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzerTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzerTest.kt
deleted file mode 100644
index 051659bfc9..0000000000
--- a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzerTest.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan
-
-import android.graphics.Bitmap
-import androidx.camera.core.ImageProxy
-import com.google.common.truth.*
-import com.google.mlkit.vision.common.InputImage
-import com.simprints.infra.logging.LoggingConstants
-import com.simprints.infra.uibase.camera.qrscan.usecase.CropBitmapAreaForDetectionUseCase
-import com.simprints.testtools.common.coroutines.TestCoroutineRule
-import io.mockk.*
-import io.mockk.impl.annotations.MockK
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-
-internal class QrCodeAnalyzerTest {
- @get:Rule
- val testCoroutineRule = TestCoroutineRule()
-
- @MockK
- lateinit var qrCodeDetector: QrCodeDetector
-
- @MockK
- lateinit var imageProxy: ImageProxy
-
- private lateinit var qrCodeProducer: QrCodeAnalyzer
- private lateinit var cropUseCase: CropBitmapAreaForDetectionUseCase
- private lateinit var bitmap: Bitmap
-
- @Before
- fun setUp() {
- MockKAnnotations.init(this, relaxed = true)
- bitmap = mockk()
- cropUseCase = mockk(relaxed = true)
- }
-
- private fun initQrCodeProducer(cropConfig: QrCodeAnalyzer.CropConfig?) {
- qrCodeProducer = QrCodeAnalyzer(
- qrCodeDetectorFactory = mockk {
- every { create(any()) } returns qrCodeDetector
- },
- bgDispatcher = testCoroutineRule.testCoroutineDispatcher,
- cropConfig = cropConfig,
- crashReportTag = LoggingConstants.CrashReportTag.LOGIN,
- cropBitmapAreaForDetectionUseCase = cropUseCase
- )
- }
-
- @Test
- fun `should not trigger detector when no images obtained`() {
- initQrCodeProducer(cropConfig = null)
- every { imageProxy.image } returns null
- qrCodeProducer.analyze(imageProxy)
-
- coVerify(exactly = 0) { qrCodeDetector.detectInImage(any()) }
- }
-
- @Test
- fun `should send RQ code value to flow`() = runTest {
- initQrCodeProducer(cropConfig = null)
- every { imageProxy.image } returns mockk()
- coEvery { qrCodeDetector.detectInImage(any()) } returns "mock_value"
-
- qrCodeProducer.analyze(imageProxy)
- val code = qrCodeProducer.scannedCode.first()
-
- Truth.assertThat(code).isEqualTo("mock_value")
- }
-
- @Test
- fun `should close image proxy with unsuccessful scan`() {
- initQrCodeProducer(cropConfig = null)
- coEvery { qrCodeDetector.detectInImage(any()) } throws Throwable()
-
- qrCodeProducer.analyze(imageProxy)
-
- verify { imageProxy.close() }
- }
-
- @Test
- fun `should emit code when cropConfig is provided`() = runTest {
- val cropConfig = mockk()
- val expectedQrValue = "expectedQrValue"
- initQrCodeProducer(cropConfig)
-
- mockkStatic(InputImage::class)
- every { InputImage.fromBitmap(any(), any()) } returns mockk(relaxed = true)
- every { imageProxy.image } returns mockk(relaxed = true)
- every { imageProxy.toBitmap() } returns bitmap
- every { cropUseCase.invoke(bitmap, cropConfig) } returns bitmap
- coEvery { qrCodeDetector.detectInImage(any()) } returns expectedQrValue
-
- qrCodeProducer.analyze(imageProxy)
- val scannedCode = qrCodeProducer.scannedCode.first()
- Truth.assertThat(scannedCode).isEqualTo(expectedQrValue)
- }
-
-}
diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCaseTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCaseTest.kt
deleted file mode 100644
index c7e8168bb9..0000000000
--- a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCaseTest.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.graphics.Bitmap
-import android.graphics.Rect
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.common.truth.Truth.*
-import com.simprints.infra.uibase.camera.qrscan.QrCodeAnalyzer
-import io.mockk.*
-import io.mockk.impl.annotations.MockK
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-internal class CropBitmapAreaForDetectionUseCaseTest {
-
- @MockK
- lateinit var rotateIfNeeded: RotateToPortraitIfNeededUseCase
-
- @MockK
- lateinit var mapCropRectToImageSpace: MapCropRectToImageSpaceUseCase
-
- @MockK
- lateinit var cropBitmapToRect: CropBitmapToRectUseCase
-
- @MockK
- lateinit var originalBitmap: Bitmap
-
- @MockK
- lateinit var rotatedBitmap: Bitmap
-
- @MockK
- lateinit var croppedBitmap: Bitmap
-
- private lateinit var useCase: CropBitmapAreaForDetectionUseCase
-
- private val cropConfig = QrCodeAnalyzer.CropConfig(
- rect = Rect(0, 0, 10, 10),
- orientation = 0,
- rootViewWidth = 100,
- rootViewHeight = 100
- )
-
- @Before
- fun setUp() {
- MockKAnnotations.init(this, relaxed = true)
-
- every { rotatedBitmap.width } returns 50
- every { rotatedBitmap.height } returns 50
-
- useCase = CropBitmapAreaForDetectionUseCase(
- rotateIfNeeded,
- mapCropRectToImageSpace,
- cropBitmapToRect
- )
- }
-
- @Test
- fun `returns cropped bitmap when rect is inside bounds`() {
- val mappedRect = Rect(5, 5, 15, 15)
-
- every { rotateIfNeeded(originalBitmap, 0) } returns rotatedBitmap
- every { mapCropRectToImageSpace(any(), any(), any(), any(), any()) } returns mappedRect
- every { cropBitmapToRect(rotatedBitmap, mappedRect) } returns croppedBitmap
-
- val result = useCase(originalBitmap, cropConfig)
-
- assertThat(result).isEqualTo(croppedBitmap)
- verify { rotateIfNeeded(bitmap = originalBitmap, orientation = 0) }
- verify {
- mapCropRectToImageSpace(
- cropRectInRoot = cropConfig.rect,
- rootWidth = 100,
- rootHeight = 100,
- imageWidth = 50,
- imageHeight = 50
- )
- }
- verify { cropBitmapToRect(source = rotatedBitmap, cropRect = mappedRect) }
- }
-
- @Test
- fun `returns original bitmap when rect is out of bounds`() {
- val mappedRect = Rect(-5, 5, 15, 15)
-
- every { rotateIfNeeded(originalBitmap, 0) } returns rotatedBitmap
- every { mapCropRectToImageSpace(any(), any(), any(), any(), any()) } returns mappedRect
-
- val result = useCase(originalBitmap, cropConfig)
-
- assertThat(result).isEqualTo(originalBitmap)
- verify { rotateIfNeeded(bitmap = originalBitmap, orientation = 0) }
- verify {
- mapCropRectToImageSpace(
- cropRectInRoot = cropConfig.rect,
- rootWidth = 100,
- rootHeight = 100,
- imageWidth = 50,
- imageHeight = 50
- )
- }
- verify(exactly = 0) { cropBitmapToRect(any(), any()) }
- }
-}
diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCaseTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCaseTest.kt
deleted file mode 100644
index b731e61304..0000000000
--- a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCaseTest.kt
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.graphics.Bitmap
-import android.graphics.Rect
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.common.truth.Truth.*
-import io.mockk.*
-import io.mockk.impl.annotations.MockK
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-internal class CropBitmapToRectUseCaseTest {
-
- @MockK
- lateinit var sourceBitmap: Bitmap
-
- @MockK
- lateinit var croppedBitmap: Bitmap
-
- private lateinit var useCase: CropBitmapToRectUseCase
-
- private val w800 = 800
- private val h600 = 600
- private val x100 = 100
- private val y100 = 100
- private val w200 = 200
- private val h150 = 150
- private val x0 = 0
- private val y0 = 0
- private val x50 = 50
- private val y50 = 50
- private val w100 = 100
- private val h100 = 100
- private val x900 = 900
- private val y700 = 700
- private val x750 = 750
- private val y550 = 550
-
- @Before
- fun setUp() {
- MockKAnnotations.init(this, relaxed = true)
-
- every { sourceBitmap.width } returns w800
- every { sourceBitmap.height } returns h600
-
- useCase = CropBitmapToRectUseCase()
- }
-
- @Test
- fun `crops bitmap with rect inside bounds`() {
- val cropRect = Rect(x100, y100, x100 + w200, y100 + h150)
- runUseCaseTest(cropRect, x100, y100, w200, h150)
- }
-
- @Test
- fun `crops bitmap with rect at origin`() {
- val cropRect = Rect(x0, y0, w100, h100)
- runUseCaseTest(cropRect, x0, y0, w100, h100)
- }
-
- @Test
- fun `coerces negative left coordinate to zero`() {
- val cropRect = Rect(-x50, y100, -x50 + w100, y100 + h100)
- runUseCaseTest(cropRect, x0, y100, w100, h100)
- }
-
- @Test
- fun `coerces negative top coordinate to zero`() {
- val cropRect = Rect(x100, -y50, x100 + w100, -y50 + h100)
- runUseCaseTest(cropRect, x100, x0, w100, h100)
- }
-
- @Test
- fun `coerces left coordinate exceeding bitmap width`() {
- val cropRect = Rect(x900, y100, x900 + w100, y100 + h100)
- runUseCaseTest(cropRect, w800, y100, x0, h100)
- }
-
- @Test
- fun `coerces top coordinate exceeding bitmap height`() {
- val cropRect = Rect(x100, y700, x100 + w100, y700 + h100)
- runUseCaseTest(cropRect, x100, h600, w100, x0)
- }
-
- @Test
- fun `limits width when crop extends beyond bitmap width`() {
- val cropRect = Rect(x750, y100, x750 + w200, y100 + h100)
- val expectedWidth = w800 - x750
- runUseCaseTest(cropRect, x750, y100, expectedWidth, h100)
- }
-
- @Test
- fun `limits height when crop extends beyond bitmap height`() {
- val cropRect = Rect(x100, y550, x100 + w100, y550 + h150)
- val expectedHeight = h600 - y550
- runUseCaseTest(cropRect, x100, y550, w100, expectedHeight)
- }
-
- @Test
- fun `handles rect completely outside bounds with adjusted coordinates`() {
- val cropRect = Rect(x900, y700, x900 + w100, y700 + h100)
- runUseCaseTest(cropRect, w800, h600, x0, x0)
- }
-
- private fun runUseCaseTest(
- cropRect: Rect,
- expectedLeft: Int,
- expectedTop: Int,
- expectedWidth: Int,
- expectedHeight: Int
- ) {
- mockkStatic(Bitmap::class)
-
- every {
- Bitmap.createBitmap(
- sourceBitmap,
- expectedLeft,
- expectedTop,
- expectedWidth,
- expectedHeight
- )
- } returns croppedBitmap
-
- val result = useCase(sourceBitmap, cropRect)
-
- assertThat(result).isEqualTo(croppedBitmap)
- verify {
- Bitmap.createBitmap(
- sourceBitmap,
- expectedLeft,
- expectedTop,
- expectedWidth,
- expectedHeight
- )
- }
- unmockkStatic(Bitmap::class)
- }
-}
diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCaseTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCaseTest.kt
deleted file mode 100644
index 8a73f0c77c..0000000000
--- a/infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCaseTest.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package com.simprints.infra.uibase.camera.qrscan.usecase
-
-import android.content.res.Configuration
-import android.graphics.Bitmap
-import android.graphics.Matrix
-import androidx.test.ext.junit.runners.*
-import com.google.common.truth.Truth.*
-import io.mockk.*
-import io.mockk.impl.annotations.MockK
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-internal class RotateToPortraitIfNeededUseCaseTest {
-
- @MockK
- lateinit var landscapeBitmap: Bitmap
-
- @MockK
- lateinit var portraitBitmap: Bitmap
-
- @MockK
- lateinit var rotatedBitmap: Bitmap
-
- private lateinit var useCase: RotateToPortraitIfNeededUseCase
-
- private val w800 = 800
- private val h600 = 600
- private val w600 = 600
- private val h800 = 800
-
- @Before
- fun setUp() {
- MockKAnnotations.init(this, relaxed = true)
-
- every { landscapeBitmap.width } returns w800
- every { landscapeBitmap.height } returns h600
- every { portraitBitmap.width } returns w600
- every { portraitBitmap.height } returns h800
-
- useCase = RotateToPortraitIfNeededUseCase()
- }
-
- @Test
- fun `rotates landscape bitmap when device is in portrait orientation`() {
- mockkStatic(Bitmap::class)
- every {
- Bitmap.createBitmap(
- landscapeBitmap,
- 0,
- 0,
- w800,
- h600,
- any(),
- true
- )
- } returns rotatedBitmap
-
- val result = useCase(landscapeBitmap, Configuration.ORIENTATION_PORTRAIT)
-
- assertThat(result).isEqualTo(rotatedBitmap)
- verify {
- Bitmap.createBitmap(
- landscapeBitmap,
- 0,
- 0,
- w800,
- h600,
- any(),
- true
- )
- }
- unmockkStatic(Bitmap::class)
- }
-
- @Test
- fun `returns original bitmap when landscape bitmap and device is in landscape orientation`() {
- runUseCaseTest(source = landscapeBitmap, orientation = Configuration.ORIENTATION_LANDSCAPE, expected = landscapeBitmap)
- }
-
- @Test
- fun `returns original bitmap when portrait bitmap and device is in portrait orientation`() {
- runUseCaseTest(source = portraitBitmap, orientation = Configuration.ORIENTATION_PORTRAIT, expected = portraitBitmap)
- }
-
- @Test
- fun `returns original bitmap when portrait bitmap and device is in landscape orientation`() {
- runUseCaseTest(source = portraitBitmap, orientation = Configuration.ORIENTATION_LANDSCAPE, expected = portraitBitmap)
- }
-
- @Test
- fun `returns original bitmap when device orientation is undefined`() {
- runUseCaseTest(source = landscapeBitmap, orientation = Configuration.ORIENTATION_UNDEFINED, expected = landscapeBitmap)
- }
-
- @Test
- fun `returns original bitmap when bitmap is square and device is in portrait orientation`() {
- val squareBitmap = mockk()
- every { squareBitmap.width } returns w600
- every { squareBitmap.height } returns w600
-
- runUseCaseTest(source = squareBitmap, orientation = Configuration.ORIENTATION_PORTRAIT, expected = squareBitmap)
- }
-
- private fun runUseCaseTest(source: Bitmap, orientation: Int, expected: Bitmap) {
- val result = useCase(source, orientation)
- assertThat(result).isEqualTo(expected)
- }
-}