From 77b1aedc07cea4a2b98913b6ba3b003948ffb915 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 6 Aug 2026 16:11:09 +0300 Subject: [PATCH 1/2] MS-1493 Delete now unused camera helpers from ui-base --- .../ExternalCredentialScanOcrFragment.kt | 4 - infra/camera/build.gradle.kts | 5 +- infra/ui-base/build.gradle.kts | 5 - .../camera/qrscan/CameraFocusManager.kt | 127 ---------------- .../uibase/camera/qrscan/CameraHelper.kt | 103 ------------- .../uibase/camera/qrscan/QrCodeAnalyzer.kt | 88 ----------- .../uibase/camera/qrscan/QrCodeDetector.kt | 57 ------- .../infra/uibase/camera/qrscan/RawImage.kt | 8 - .../CropBitmapAreaForDetectionUseCase.kt | 37 ----- .../qrscan/usecase/CropBitmapToRectUseCase.kt | 23 --- .../usecase/MapCropRectToImageSpaceUseCase.kt | 35 ----- .../RotateToPortraitIfNeededUseCase.kt | 29 ---- .../camera/qrscan/QrCodeAnalyzerTest.kt | 100 ------------- .../CropBitmapAreaForDetectionUseCaseTest.kt | 104 ------------- .../usecase/CropBitmapToRectUseCaseTest.kt | 140 ------------------ .../RotateToPortraitIfNeededUseCaseTest.kt | 110 -------------- 16 files changed, 4 insertions(+), 971 deletions(-) delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraFocusManager.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraHelper.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzer.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/QrCodeDetector.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/RawImage.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCase.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCase.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/MapCropRectToImageSpaceUseCase.kt delete mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCase.kt delete mode 100644 infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/QrCodeAnalyzerTest.kt delete mode 100644 infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapAreaForDetectionUseCaseTest.kt delete mode 100644 infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/CropBitmapToRectUseCaseTest.kt delete mode 100644 infra/ui-base/src/test/java/com/simprints/infra/uibase/camera/qrscan/usecase/RotateToPortraitIfNeededUseCaseTest.kt 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/infra/camera/build.gradle.kts b/infra/camera/build.gradle.kts index 0863f8c188..afb228ac3f 100644 --- a/infra/camera/build.gradle.kts +++ b/infra/camera/build.gradle.kts @@ -9,6 +9,9 @@ android { dependencies { implementation(libs.androidX.cameraX.core) implementation(libs.androidX.cameraX.lifecycle) - implementation(libs.androidX.cameraX.view) + + // Exported so that dependant modules have access to PreviewView + api(libs.androidX.cameraX.view) + implementation(libs.playServices.barcode) } diff --git a/infra/ui-base/build.gradle.kts b/infra/ui-base/build.gradle.kts index 486ebc6ad3..1c5c2452a3 100644 --- a/infra/ui-base/build.gradle.kts +++ b/infra/ui-base/build.gradle.kts @@ -36,10 +36,5 @@ dependencies { api(libs.androidX.navigation.fragment) - api(libs.androidX.cameraX.core) - api(libs.androidX.cameraX.lifecycle) - api(libs.androidX.cameraX.view) - api(libs.playServices.barcode) - testImplementation(project(":infra:test-tools")) } diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraFocusManager.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraFocusManager.kt deleted file mode 100644 index 302984baaa..0000000000 --- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/camera/qrscan/CameraFocusManager.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.simprints.infra.uibase.camera.qrscan - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import android.view.ViewTreeObserver.OnGlobalLayoutListener -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.LoggingConstants.CrashReportTag.LOGIN -import com.simprints.infra.logging.Simber -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -@ExcludedFromGeneratedTestCoverageReports( - reason = "These are UI utilities for focus controls in the camera preview", -) -class CameraFocusManager @AssistedInject constructor( - @Assisted private val crashReportTag: LoggingConstants.CrashReportTag, -) { - @AssistedFactory - interface Factory { - fun create(crashReportTag: LoggingConstants.CrashReportTag): CameraFocusManager - } - - @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) = 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 : 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) - } -} From 5a32afa52249dd8cc474a66a4402c7072b016402 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 6 Aug 2026 16:56:35 +0300 Subject: [PATCH 2/2] SM-1493 Move camera-related custom views to the infra module --- .../fragment_external_credential_scan_ocr.xml | 8 ++--- .../fragment_external_credential_scan_qr.xml | 6 ++-- .../fragment_external_credential_scan_ocr.xml | 24 +++++++-------- .../fragment_external_credential_scan_qr.xml | 6 ++-- .../fragment_external_credential_scan_ocr.xml | 20 ++++++------- .../fragment_external_credential_scan_qr.xml | 6 ++-- infra/camera/build.gradle.kts | 3 ++ .../infra/camera/view}/CaptureMaskView.kt | 6 ++-- .../infra/camera/view}/CaptureProgressView.kt | 6 ++-- .../infra/camera/view}/CaptureTargetView.kt | 6 ++-- .../camera}/view/PermissionRequestView.kt | 30 ++++++++++++++----- .../layout/view_camera_permission_request.xml | 0 .../src/main/res/values/attrs.xml | 0 13 files changed, 69 insertions(+), 52 deletions(-) rename infra/{ui-base/src/main/java/com/simprints/infra/view/imagecapture => camera/src/main/java/com/simprints/infra/camera/view}/CaptureMaskView.kt (95%) rename infra/{ui-base/src/main/java/com/simprints/infra/view/imagecapture => camera/src/main/java/com/simprints/infra/camera/view}/CaptureProgressView.kt (98%) rename infra/{ui-base/src/main/java/com/simprints/infra/view/imagecapture => camera/src/main/java/com/simprints/infra/camera/view}/CaptureTargetView.kt (97%) rename infra/{ui-base/src/main/java/com/simprints/infra => camera/src/main/java/com/simprints/infra/camera}/view/PermissionRequestView.kt (69%) rename infra/{ui-base => camera}/src/main/res/layout/view_camera_permission_request.xml (100%) rename infra/{ui-base => camera}/src/main/res/values/attrs.xml (100%) 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" /> - - - -