From 2d3971f87a8ace752d1d0f0c8f8808f483cf05c1 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 5 Aug 2026 16:59:28 +0300 Subject: [PATCH] MS-1496 Frame provider implementation in ocr capture --- feature/external-credential/build.gradle.kts | 3 + .../ExternalCredentialScanOcrFragment.kt | 192 ++++++----------- .../ExternalCredentialScanOcrViewModel.kt | 60 +++--- .../screens/scanocr/model/OcrCropConfig.kt | 10 - .../usecase/BuildOcrCropConfigUseCase.kt | 23 --- .../usecase/CropDocumentFromPreviewUseCase.kt | 32 --- .../NormalizeBitmapToPreviewUseCase.kt | 68 ------ .../usecase/ProvideCameraListenerUseCase.kt | 61 ------ .../ExternalCredentialScanOcrViewModelTest.kt | 88 +++----- .../usecase/BuildOcrCropConfigUseCaseTest.kt | 88 -------- .../CropDocumentFromPreviewUseCaseTest.kt | 193 ------------------ .../NormalizeBitmapToPreviewUseCaseTest.kt | 103 ---------- 12 files changed, 126 insertions(+), 795 deletions(-) delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt delete mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt delete mode 100644 feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCaseTest.kt delete mode 100644 feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCaseTest.kt delete mode 100644 feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCaseTest.kt diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index 46152442b2..10452282af 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -16,6 +16,9 @@ dependencies { implementation(project(":infra:matching")) implementation(project(":infra:events")) implementation(project(":infra:credential-store")) + + implementation(project(":infra:camera")) + implementation(libs.androidX.cameraX.view) implementation(libs.mlkit.text.recognition) } 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 c5690bfcbd..0245323d43 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 @@ -8,11 +8,6 @@ import android.view.View import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCaptureException -import androidx.camera.core.ImageProxy -import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.view.isInvisible @@ -24,6 +19,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.simprints.core.DispatcherBG @@ -35,25 +31,24 @@ import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanOcrBinding import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel import com.simprints.feature.externalcredential.screens.scanocr.model.LightingConditionsAssessment -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -import com.simprints.feature.externalcredential.screens.scanocr.usecase.BuildOcrCropConfigUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.ProvideCameraListenerUseCase +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrConfig +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetBoundsRelativeToParentUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +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 import com.simprints.infra.uibase.view.fadeIn import com.simprints.infra.uibase.view.fadeOut import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject import com.simprints.infra.resources.R as IDR @@ -61,6 +56,7 @@ import com.simprints.infra.resources.R as IDR internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_external_credential_scan_ocr) { private val args: ExternalCredentialScanOcrFragmentArgs by navArgs() private val binding by viewBinding(FragmentExternalCredentialScanOcrBinding::bind) + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() private val viewModel by viewModels { object : ViewModelProvider.Factory { @@ -75,7 +71,6 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex ActivityResultContracts.RequestPermission(), ) { granted -> val cameraPermissionStatus = requireActivity().permissionFromResult(CAMERA, granted) - previousPermissionStatus = cameraPermissionStatus if (cameraPermissionStatus == PermissionStatus.Granted) { initializeFragment() } else { @@ -83,22 +78,20 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex renderNoPermission(shouldOpenPhoneSettings) } } - private var previousPermissionStatus: PermissionStatus? = null - private lateinit var cameraExecutor: ExecutorService - private lateinit var imageAnalysis: ImageAnalysis - private lateinit var imageCapture: ImageCapture + private var shouldAutoRequestCameraPermission: Boolean = true private var isAnimatingCompletion: Boolean = false private var pendingFinishAction: (() -> Unit)? = null - private var imagePreProcessingJob: Job? = null + + private val cameraInitLock = Mutex() @Inject - lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory + lateinit var cameraFrameProvider: CameraFrameProvider @Inject - lateinit var buildOcrCropConfigUseCase: BuildOcrCropConfigUseCase + lateinit var getBoundsRelativeToParentUseCase: GetBoundsRelativeToParentUseCase @Inject - lateinit var provideCameraListenerUseCase: ProvideCameraListenerUseCase + lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory @Inject lateinit var cameraFocusManagerFactory: CameraFocusManager.Factory @@ -113,6 +106,9 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex ) { super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) + + initObservers() + setUpFrameProcessing() Simber.i("ExternalCredentialScanOcrFragment started", tag = MULTI_FACTOR_ID) } @@ -120,26 +116,15 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex super.onResume() when (val currentPermission = requireActivity().getCurrentPermissionStatus(CAMERA)) { PermissionStatus.Granted -> initializeFragment() - PermissionStatus.Denied -> { - // Permission dialog was already displayed, and user denied permissions. Showing rationale so to avoid constantly-appearing - // system dialog. - if (previousPermissionStatus == currentPermission) { - renderNoPermission(shouldOpenPhoneSettings = false) - } else { - launchPermissionRequest.launch(CAMERA) - } - } - - PermissionStatus.DeniedNeverAskAgain -> { - // Requesting system dialog just in case. Some devices faulty report 'DeniedNeverAskAgain' status when it is actually 'Denied' - launchPermissionRequest.launch(CAMERA) - renderNoPermission(shouldOpenPhoneSettings = true) + PermissionStatus.Denied, PermissionStatus.DeniedNeverAskAgain -> if (shouldAutoRequestCameraPermission) { + requestCameraPermission() + } else { + renderNoPermission(shouldOpenPhoneSettings = currentPermission == PermissionStatus.DeniedNeverAskAgain) } } } override fun onDestroyView() { - stopImageProcessing() stopCamera() clearAnimations() super.onDestroyView() @@ -150,11 +135,14 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex isAnimatingCompletion = false } - private fun initializeFragment() { - initObservers() - initCamera(onComplete = { - setUpFrameProcessing() - }) + private fun initializeFragment() = viewLifecycleOwner.lifecycleScope.launch { + val ocrConfig = viewModel.awaitOcrConfig() + cameraInitLock.withLock { + if (!cameraFrameProvider.isInitialised()) { + initCamera(ocrConfig) + } + } + renderInitialState() } private fun initObservers() { @@ -177,7 +165,6 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex 100, durationMs = PROGRESS_FINISH_REMAINING_MS, onComplete = { - stopImageProcessing() viewModel.processOcrResultsAndFinish() }, interpolator = AccelerateInterpolator(), @@ -198,33 +185,23 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex ) } - private fun initCamera(onComplete: () -> Unit) { - if (::cameraExecutor.isInitialized) { + private suspend fun initCamera(ocrConfig: OcrConfig) { + if (cameraFrameProvider.isInitialised()) { return } - - cameraExecutor = Executors.newSingleThreadExecutor() - val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) - val cameraListener = provideCameraListenerUseCase( - cameraProviderFuture = cameraProviderFuture, - surfaceProvider = binding.preview.surfaceProvider, - viewLifecycleOwner = viewLifecycleOwner, - onImageAnalysisReady = { analysis -> - imageAnalysis = analysis - onComplete() - }, - onImageCaptureReady = { capture -> - imageCapture = capture - }, - onCameraReady = { camera -> - if (lifecycle.currentState == Lifecycle.State.RESUMED) { - val cameraFocusManager = cameraFocusManagerFactory.create(MULTI_FACTOR_ID) - cameraFocusManager.setUpFocusOnTap(binding.preview, camera) - cameraFocusManager.setUpAutoFocus(binding.preview, camera) - } - }, + binding.preview.awaitLayout() // Wait for the views to be properly laid out + val targetRect = getBoundsRelativeToParentUseCase( + parent = binding.preview, + child = binding.documentScannerArea, ) - cameraProviderFuture.addListener(cameraListener, ContextCompat.getMainExecutor(requireContext())) + cameraFrameProvider.initialiseCamera( + lifecycleOwner = viewLifecycleOwner, + previewView = binding.preview, + target = targetRect, + highResolution = ocrConfig.useHighRes, + ) { + Simber.e("Camera binding failed in OCR", it, MULTI_FACTOR_ID) + } } private fun renderProgress(state: ScanOcrState.ScanningInProgress) = with(binding) { @@ -288,6 +265,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) { + stopCamera() with(binding) { instructionsText.isVisible = false progressContainer.isInvisible = true @@ -316,7 +294,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex body = bodyText, buttonText = IDR.string.face_capture_permission_action, onClickListener = { - launchPermissionRequest.launch(CAMERA) + requestCameraPermission() }, ) } @@ -324,78 +302,34 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } } - private fun setUpFrameProcessing() { - imageAnalysis.setAnalyzer(cameraExecutor) { videoFrame: ImageProxy -> - if (viewModel.isProcessingImage.get()) { - videoFrame.close() - return@setAnalyzer - } + private fun requestCameraPermission() { + shouldAutoRequestCameraPermission = false + launchPermissionRequest.launch(CAMERA) + } - // Processing frames as often as we can while camera feedback is displayed to the user - viewModel.imageProcessingStarted() - if (viewModel.isScanningInProgress && viewModel.ocrConfig?.useHighRes == true) { - // For hi-res OCR we don't need the frame and will capture a new image instead - videoFrame.close() - captureHighResImageForOcr { highResImage -> - processImage(highResImage) + private fun setUpFrameProcessing() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + viewModel.isProcessingImage.collect { isProcessing -> + cameraFrameProvider.setFrameEmissionEnabled(!isProcessing) + } } - } else { - processImage(videoFrame) - } - } - } - private fun processImage(imageProxy: ImageProxy) { - imagePreProcessingJob?.cancel() - imagePreProcessingJob = lifecycleScope.launch(bgDispatcher) { - try { - if (isActive) { - val (bitmap, imageInfo) = imageProxy.toBitmap() to imageProxy.imageInfo - val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase( - rotationDegrees = imageInfo.rotationDegrees, - cameraPreview = binding.preview, - documentScannerArea = binding.documentScannerArea, - ) - viewModel.processImage(bitmap = bitmap, cropConfig) - } else { - Simber.i( - "Unable to run image processing, coroutine context is cancelled", - tag = MULTI_FACTOR_ID, - ) + launch { + cameraFrameProvider.frames.collect { frame -> + viewModel.imageProcessingStarted() + viewModel.processImage(frame) + } } - } finally { - imageProxy.close() } } } - private fun captureHighResImageForOcr(onImageCaptured: (ImageProxy) -> Unit) { - imageCapture.takePicture( - cameraExecutor, - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(imageProxy: ImageProxy) { - onImageCaptured(imageProxy) - } - - override fun onError(e: ImageCaptureException) { - Simber.e("Photo capture failed in OCR", e, MULTI_FACTOR_ID) - } - }, - ) - } - private fun stopCamera() { - if (::cameraExecutor.isInitialized) { - cameraExecutor.shutdown() - } - } - - private fun stopImageProcessing() { - imagePreProcessingJob?.cancel() - if (::imageAnalysis.isInitialized) { - imageAnalysis.clearAnalyzer() + if (cameraFrameProvider.isInitialised()) { + cameraFrameProvider.release() } - viewModel.imageProcessingStopped() } /** diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt index 19a087abea..e1f3b7f701 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt @@ -1,6 +1,5 @@ package com.simprints.feature.externalcredential.screens.scanocr -import android.graphics.Bitmap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -14,16 +13,15 @@ import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanocr.model.LightingConditionsAssessment import com.simprints.feature.externalcredential.screens.scanocr.model.LightingConditionsAssessmentConfig import com.simprints.feature.externalcredential.screens.scanocr.model.OcrConfig -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.model.ScannedMfidDocument import com.simprints.feature.externalcredential.screens.scanocr.usecase.BuildScannedCredentialResultUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.CropDocumentFromPreviewUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentConfigUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.NormalizeBitmapToPreviewUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.ScanMfidDocumentUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import com.simprints.infra.camera.Frame +import com.simprints.infra.camera.postprocess.FrameCropToTargetUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.experimental import com.simprints.infra.logging.Simber @@ -33,16 +31,17 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( @Assisted val ocrDocumentType: OcrDocumentType, private val timeHelper: TimeHelper, - private val normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase, - private val cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase, + private val frameCropToTargetUseCase: FrameCropToTargetUseCase, private val scanMfidDocumentUseCase: ScanMfidDocumentUseCase, private val buildScannedCredentialResultUseCase: BuildScannedCredentialResultUseCase, private val getLightingConditionsAssessmentConfig: GetLightingConditionsAssessmentConfigUseCase, @@ -56,7 +55,9 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } private var scannedMfidDocuments: List = emptyList() - val isProcessingImage = AtomicBoolean(false) + + val isProcessingImage: StateFlow + field = MutableStateFlow(false) val isOcrActive: Boolean get() = scannedMfidDocuments.isNotEmpty() @@ -68,25 +69,23 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( private val _scanOcrStateLiveData = MutableLiveData(ocrState) val scanOcrStateLiveData: LiveData = _scanOcrStateLiveData val finishOcrEvent: LiveData> - get() = _finishOcrEvent - private val _finishOcrEvent = MutableLiveData>() - + field = MutableLiveData>() private val lightingConditionsAssessmentFlow = MutableStateFlow(null) val lightingConditionsAssessment: LiveData = lightingConditionsAssessmentFlow .filterNotNull() - .debounce(LIGHTING_CONDITIONS_ASSESSMENT_DEBOUNCE_MILLIS) + .debounce(LIGHTING_CONDITIONS_ASSESSMENT_DEBOUNCE_MILLIS.milliseconds) .asLiveData(viewModelScope.coroutineContext) private lateinit var startTime: Timestamp - var ocrConfig: OcrConfig? = null - private set + private val ocrConfigFlow = MutableStateFlow(null) + private var lightingConditionsAssessmentConfig: LightingConditionsAssessmentConfig? = null init { viewModelScope.launch { with(configRepository.getProjectConfiguration().experimental()) { - ocrConfig = OcrConfig( + ocrConfigFlow.value = OcrConfig( useHighRes = ocrUseHighRes, capturesRequired = ocrCaptures.coerceIn(OCR_CAPTURE_MIN, OCR_CAPTURE_MAX), ) @@ -95,6 +94,8 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } } + suspend fun awaitOcrConfig(): OcrConfig = ocrConfigFlow.filterNotNull().first() + private fun updateState(state: (ScanOcrState) -> ScanOcrState) { this.ocrState = state(this.ocrState) } @@ -106,7 +107,7 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } fun startScanning() { - val captureConfig = ocrConfig ?: return + val captureConfig = ocrConfigFlow.value ?: return startTime = timeHelper.now() updateState { ScanOcrState.ScanningInProgress( @@ -120,23 +121,16 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( val isScanningInProgress: Boolean get() = ocrState is ScanOcrState.ScanningInProgress - fun imageProcessingStopped() { - isProcessingImage.set(false) - } - - fun processImage( - bitmap: Bitmap, - cropConfig: OcrCropConfig, - ) { + fun processImage(frame: Frame) { viewModelScope.launch(bgDispatcher) { + imageProcessingStarted() try { val isOcrAllowed = isScanningInProgress val isLightningAssessmentEnabled = lightingConditionsAssessmentConfig != null if (!isOcrAllowed && !isLightningAssessmentEnabled) return@launch // no-op Simber.d("started image processing; with OCR: $isOcrAllowed, lighting assessment: $isLightningAssessmentEnabled") - val normalizedBitmap = normalizeBitmapToPreviewUseCase(bitmap, cropConfig) - val cropped = cropDocumentFromPreviewUseCase(bitmap = normalizedBitmap, cutoutRect = cropConfig.cutoutRect) + val cropped = frameCropToTargetUseCase(frame) lightingConditionsAssessmentConfig?.run { lightingConditionsAssessmentFlow.value = getLightingConditionsAssessment( bitmap = cropped, @@ -146,7 +140,7 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( if (!isOcrAllowed) return@launch val mfidConfig = configRepository.getProjectConfiguration().multifactorId ?: return@launch - val captureConfig = ocrConfig ?: return@launch + val captureConfig = ocrConfigFlow.value ?: return@launch val scannedMfidDocument = scanMfidDocumentUseCase(bitmap = cropped, documentType = ocrDocumentType, config = mfidConfig) ?: return@launch Simber.d("Detected OCR") @@ -162,7 +156,7 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } } } finally { - isProcessingImage.set(false) + imageProcessingStopped() } } } @@ -171,13 +165,17 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( updateState { ScanOcrState.Complete } viewModelScope.launch { val scannedCredentialResult = buildScannedCredentialResultUseCase(scannedMfidDocuments, ocrDocumentType, startTime) - _finishOcrEvent.send(scannedCredentialResult) + finishOcrEvent.send(scannedCredentialResult) scannedMfidDocuments = emptyList() } } - fun imageProcessingStarted() { - isProcessingImage.set(true) + suspend fun imageProcessingStarted() { + isProcessingImage.emit(true) + } + + suspend fun imageProcessingStopped() { + isProcessingImage.emit(false) } companion object { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt deleted file mode 100644 index 6a9540665e..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.model - -import android.graphics.Rect - -internal data class OcrCropConfig( - val rotationDegrees: Int, - val cutoutRect: Rect, - val previewViewWidth: Int, - val previewViewHeight: Int, -) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt deleted file mode 100644 index 50b9970973..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.view.View -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -import javax.inject.Inject - -internal class BuildOcrCropConfigUseCase @Inject constructor( - private val getBoundsRelativeToParentUseCase: GetBoundsRelativeToParentUseCase, -) { - operator fun invoke( - rotationDegrees: Int, - cameraPreview: View, - documentScannerArea: View, - ): OcrCropConfig { - val cutoutRect = getBoundsRelativeToParentUseCase(parent = cameraPreview, child = documentScannerArea) - return OcrCropConfig( - rotationDegrees = rotationDegrees, - cutoutRect = cutoutRect, - previewViewWidth = cameraPreview.width, - previewViewHeight = cameraPreview.height, - ) - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt deleted file mode 100644 index 4174b5c2e8..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Bitmap -import android.graphics.Rect -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID -import com.simprints.infra.logging.Simber -import javax.inject.Inject - -internal class CropDocumentFromPreviewUseCase @Inject constructor() { - operator fun invoke( - bitmap: Bitmap, - cutoutRect: Rect, - ): Bitmap { - val left = cutoutRect.left.coerceIn(0, bitmap.width) - val top = cutoutRect.top.coerceIn(0, bitmap.height) - val right = cutoutRect.right.coerceIn(left, bitmap.width) - val bottom = cutoutRect.bottom.coerceIn(top, bitmap.height) - - val width = right - left - val height = bottom - top - - return if (width <= 0 || height <= 0) { - val message = - "Invalid OCR crop dimensions: width=$width, height=$height. CutoutRect=[$cutoutRect], bitmapSize(w,h)=[${bitmap.width}x${bitmap.height}]" - Simber.e(message = message, t = IllegalStateException(message), tag = MULTI_FACTOR_ID) - // Returning original bitmap without zooming in - return bitmap - } else { - Bitmap.createBitmap(bitmap, left, top, width, height) - } - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt deleted file mode 100644 index 66154ef8da..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Bitmap -import android.graphics.Matrix -import androidx.core.graphics.scale -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -import javax.inject.Inject - -internal class NormalizeBitmapToPreviewUseCase @Inject constructor() { - /** - * Normalizes a camera capture [inputBitmap] bitmap 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 bitmap has the same aspect ratio as what the user - * sees in the camera preview, making OCR results spatially consistent with the preview overlay. - * - * @param inputBitmap the original camera capture bitmap - * @param cropConfig configuration containing rotation, preview width and height - * - * @return normalized bitmap with PreviewView dimensions and aspect ratio - */ - suspend operator fun invoke( - inputBitmap: Bitmap, - cropConfig: OcrCropConfig, - ): Bitmap { - val rotationDegrees = cropConfig.rotationDegrees - val previewViewWidth = cropConfig.previewViewWidth - val previewViewHeight = cropConfig.previewViewHeight - - // Rotate if necessary - val rotated = if (rotationDegrees != 0) { - val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } - Bitmap.createBitmap(inputBitmap, 0, 0, inputBitmap.width, inputBitmap.height, matrix, true) - } else { - inputBitmap - } - - // Center-crop to match PreviewView aspect ratio - val previewRatio = previewViewWidth.toFloat() / previewViewHeight - 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(previewViewWidth, previewViewHeight) - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt deleted file mode 100644 index fb9d8ceb22..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import androidx.camera.core.AspectRatio -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.lifecycle.LifecycleOwner -import com.google.common.util.concurrent.ListenableFuture -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID -import com.simprints.infra.logging.Simber -import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports -import javax.inject.Inject - -@ExcludedFromGeneratedTestCoverageReports("UI Code") -internal class ProvideCameraListenerUseCase @Inject constructor() { - operator fun invoke( - cameraProviderFuture: ListenableFuture, - surfaceProvider: Preview.SurfaceProvider, - viewLifecycleOwner: LifecycleOwner, - onImageAnalysisReady: (ImageAnalysis) -> Unit, - onImageCaptureReady: (ImageCapture) -> Unit, - onCameraReady: (Camera) -> Unit, - ) = Runnable { - val cameraProvider = cameraProviderFuture.get() - val aspectRatio = AspectRatio.RATIO_16_9 - val preview = Preview - .Builder() - .setTargetAspectRatio(aspectRatio) - .build() - .also { - it.setSurfaceProvider(surfaceProvider) - } - - val imageCapture = ImageCapture - .Builder() - .setTargetAspectRatio(aspectRatio) - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - - val imageAnalysis = ImageAnalysis - .Builder() - .setTargetAspectRatio(aspectRatio) - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - try { - cameraProvider.unbindAll() - val camera = cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis) - onImageAnalysisReady(imageAnalysis) - onImageCaptureReady(imageCapture) - onCameraReady(camera) - } catch (e: Exception) { - Simber.e("Camera binding failed in OCR", e, MULTI_FACTOR_ID) - } - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt index 54c82e74c6..1532bbff89 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt @@ -7,16 +7,15 @@ import com.jraska.livedata.test import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanocr.model.LightingConditionsAssessment -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.model.ScannedMfidDocument import com.simprints.feature.externalcredential.screens.scanocr.usecase.BuildScannedCredentialResultUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.CropDocumentFromPreviewUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentConfigUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.NormalizeBitmapToPreviewUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.ScanMfidDocumentUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import com.simprints.infra.camera.Frame +import com.simprints.infra.camera.postprocess.FrameCropToTargetUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.MFID_LIGHTING_CONDITIONS_ASSESSMENT_ENABLED import com.simprints.infra.config.store.models.ProjectConfiguration @@ -46,10 +45,7 @@ internal class ExternalCredentialScanOcrViewModelTest { private lateinit var timeHelper: TimeHelper @MockK - private lateinit var normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase - - @MockK - private lateinit var cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase + private lateinit var frameCropToTargetUseCase: FrameCropToTargetUseCase @MockK private lateinit var scanMfidDocumentUseCase: ScanMfidDocumentUseCase @@ -64,10 +60,7 @@ internal class ExternalCredentialScanOcrViewModelTest { private lateinit var configRepository: ConfigRepository @MockK - private lateinit var bitmap: Bitmap - - @MockK - private lateinit var cropConfig: OcrCropConfig + private lateinit var frame: Frame private lateinit var viewModel: ExternalCredentialScanOcrViewModel @@ -93,8 +86,7 @@ internal class ExternalCredentialScanOcrViewModelTest { return ExternalCredentialScanOcrViewModel( ocrDocumentType = documentType, timeHelper = timeHelper, - normalizeBitmapToPreviewUseCase = normalizeBitmapToPreviewUseCase, - cropDocumentFromPreviewUseCase = cropDocumentFromPreviewUseCase, + frameCropToTargetUseCase = frameCropToTargetUseCase, scanMfidDocumentUseCase = scanMfidDocumentUseCase, buildScannedCredentialResultUseCase = buildScannedCredentialResultUseCase, getLightingConditionsAssessmentConfig = GetLightingConditionsAssessmentConfigUseCase(configRepository), @@ -116,37 +108,34 @@ internal class ExternalCredentialScanOcrViewModelTest { } @Test - fun `imageProcessingStopped resets the flag for image processing`() { + fun `imageProcessingStopped resets the flag for image processing`() = runTest { viewModel.imageProcessingStarted() - assertThat(viewModel.isProcessingImage.get()).isTrue() + assertThat(viewModel.isProcessingImage.value).isTrue() viewModel.imageProcessingStopped() - assertThat(viewModel.isProcessingImage.get()).isFalse() + assertThat(viewModel.isProcessingImage.value).isFalse() } @Test fun `processImage updates detected blocks and state when OCR successful`() = runTest { val mockScannedDocument = mockk() - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument val observer = viewModel.scanOcrStateLiveData.test() viewModel.imageProcessingStarted() viewModel.startScanning() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) val state = observer.value() as ScanOcrState.ScanningInProgress assertThat(state.successfulCaptures).isEqualTo(1) - assertThat(viewModel.isProcessingImage.get()).isFalse() + assertThat(viewModel.isProcessingImage.value).isFalse() assertThat(viewModel.isOcrActive).isTrue() } @Test fun `processImage updates lighting conditions after debounce`() = runTest { - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() val lightingConditionsAssessment = LightingConditionsAssessment.TOO_DIM viewModel = initViewModel( @@ -156,13 +145,12 @@ internal class ExternalCredentialScanOcrViewModelTest { ), ) runCurrent() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { getLightingConditionsAssessment(mockCroppedBitmap, any()) } returns lightingConditionsAssessment val observer = viewModel.lightingConditionsAssessment.test() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) runCurrent() observer.assertNoValue() @@ -179,11 +167,10 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage skips normalization and OCR when scanning not in progress and lighting is disabled`() = runTest { val observer = viewModel.scanOcrStateLiveData.test() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) assertThat(observer.value()).isInstanceOf(ScanOcrState.NotScanning::class.java) - coVerify(exactly = 0) { normalizeBitmapToPreviewUseCase.invoke(any(), any()) } - coVerify(exactly = 0) { cropDocumentFromPreviewUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { frameCropToTargetUseCase(frame) } coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } coVerify(exactly = 0) { getLightingConditionsAssessment.invoke(any(), any()) } assertThat(viewModel.isOcrActive).isFalse() @@ -191,7 +178,6 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage updates lighting conditions when scanning not in progress`() = runTest { - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() val lightingConditionsAssessment = LightingConditionsAssessment.TOO_BRIGHT viewModel = initViewModel( @@ -201,13 +187,12 @@ internal class ExternalCredentialScanOcrViewModelTest { ), ) runCurrent() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(any()) } returns mockCroppedBitmap coEvery { getLightingConditionsAssessment(mockCroppedBitmap, any()) } returns lightingConditionsAssessment val observer = viewModel.lightingConditionsAssessment.test() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) runCurrent() advanceTimeBy(500L) // debounce delay @@ -228,14 +213,13 @@ internal class ExternalCredentialScanOcrViewModelTest { ) runCurrent() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) runCurrent() advanceTimeBy(500L) // debounce delay runCurrent() - coVerify(exactly = 0) { normalizeBitmapToPreviewUseCase.invoke(any(), any()) } - coVerify(exactly = 0) { cropDocumentFromPreviewUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { frameCropToTargetUseCase(frame) } coVerify(exactly = 0) { getLightingConditionsAssessment(any(), any()) } } @@ -253,8 +237,7 @@ internal class ExternalCredentialScanOcrViewModelTest { viewModel = ExternalCredentialScanOcrViewModel( ocrDocumentType = documentType, timeHelper = timeHelper, - normalizeBitmapToPreviewUseCase = normalizeBitmapToPreviewUseCase, - cropDocumentFromPreviewUseCase = cropDocumentFromPreviewUseCase, + frameCropToTargetUseCase = frameCropToTargetUseCase, scanMfidDocumentUseCase = scanMfidDocumentUseCase, buildScannedCredentialResultUseCase = buildScannedCredentialResultUseCase, getLightingConditionsAssessmentConfig = GetLightingConditionsAssessmentConfigUseCase(configRepository), @@ -263,14 +246,13 @@ internal class ExternalCredentialScanOcrViewModelTest { bgDispatcher = testCoroutineRule.testCoroutineDispatcher, ) - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) runCurrent() advanceTimeBy(500L) // debounce delay runCurrent() - coVerify(exactly = 0) { normalizeBitmapToPreviewUseCase.invoke(any(), any()) } - coVerify(exactly = 0) { cropDocumentFromPreviewUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { frameCropToTargetUseCase(frame) } coVerify(exactly = 0) { getLightingConditionsAssessment(any(), any()) } configLoadingDeferred.complete(projectConfiguration) @@ -296,16 +278,14 @@ internal class ExternalCredentialScanOcrViewModelTest { fun `processOcrResultsAndFinish passes accumulated documents to build use case`() = runTest { val mockScannedDocument = mockk() val mockScannedCredentialResult = mockk() - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument coEvery { buildScannedCredentialResultUseCase(any(), documentType, any()) } returns mockScannedCredentialResult viewModel.startScanning() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) viewModel.processOcrResultsAndFinish() coVerify { buildScannedCredentialResultUseCase(listOf(mockScannedDocument), documentType, any()) } @@ -335,23 +315,21 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage does not add documents or update state once required captures are reached`() = runTest { val mockScannedDocument = mockk() - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument val observer = viewModel.scanOcrStateLiveData.test() viewModel.startScanning() val capturesRequired = (observer.value() as ScanOcrState.ScanningInProgress).scansRequired - repeat(capturesRequired) { viewModel.processImage(bitmap, cropConfig) } + repeat(capturesRequired) { viewModel.processImage(frame) } val stateAtCapacity = observer.value() as ScanOcrState.ScanningInProgress assertThat(stateAtCapacity.successfulCaptures).isEqualTo(capturesRequired) - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) assertThat(observer.value()).isEqualTo(stateAtCapacity) } @@ -359,18 +337,16 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage emits ScanningInProgress with enough captures exactly once`() = runTest { val mockScannedDocument = mockk() - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument val observer = viewModel.scanOcrStateLiveData.test() viewModel.startScanning() val capturesRequired = (observer.value() as ScanOcrState.ScanningInProgress).scansRequired - repeat(capturesRequired + 2) { viewModel.processImage(bitmap, cropConfig) } + repeat(capturesRequired + 2) { viewModel.processImage(frame) } val completionTriggers = observer .valueHistory() @@ -383,11 +359,9 @@ internal class ExternalCredentialScanOcrViewModelTest { fun `processImage does not append documents or update state after scanning is complete`() = runTest { val mockScannedDocument = mockk() val mockScannedCredentialResult = mockk() - val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() - coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap - coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument coEvery { buildScannedCredentialResultUseCase(any(), documentType, any()) } returns mockScannedCredentialResult @@ -399,7 +373,7 @@ internal class ExternalCredentialScanOcrViewModelTest { // Frame from camera arrives after Complete viewModel.imageProcessingStarted() - viewModel.processImage(bitmap, cropConfig) + viewModel.processImage(frame) assertThat(stateObserver.value()).isEqualTo(ScanOcrState.Complete) assertThat(viewModel.isOcrActive).isFalse() diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCaseTest.kt deleted file mode 100644 index df1a040d2a..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCaseTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Rect -import android.view.View -import com.google.common.truth.Truth.assertThat -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.mockk -import org.junit.Before -import org.junit.Test - -internal class BuildOcrCropConfigUseCaseTest { - private lateinit var getBoundsRelativeToParentUseCase: GetBoundsRelativeToParentUseCase - private lateinit var useCase: BuildOcrCropConfigUseCase - - private val mockCameraPreview = mockk() - private val mockDocumentScannerArea = mockk() - private val mockRect = mockk() - private val width800 = 800 - private val height600 = 600 - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - getBoundsRelativeToParentUseCase = mockk() - useCase = BuildOcrCropConfigUseCase(getBoundsRelativeToParentUseCase) - - every { mockCameraPreview.width } returns width800 - every { mockCameraPreview.height } returns height600 - every { getBoundsRelativeToParentUseCase(mockCameraPreview, mockDocumentScannerArea) } returns mockRect - } - - private fun runUseCaseTest(rotationDegrees: Int = 0) = useCase(rotationDegrees, mockCameraPreview, mockDocumentScannerArea) - - @Test - fun `creates config with correct rotation degrees`() { - val rotationDegrees = 90 - val result = runUseCaseTest(rotationDegrees) - assertThat(result.rotationDegrees).isEqualTo(rotationDegrees) - } - - @Test - fun `creates config with correct preview dimensions`() { - val result = runUseCaseTest() - assertThat(result.previewViewWidth).isEqualTo(800) - assertThat(result.previewViewHeight).isEqualTo(600) - } - - @Test - fun `creates config with cutout rect from bounds use case`() { - val result = runUseCaseTest() - assertThat(result.cutoutRect).isEqualTo(mockRect) - } - - @Test - fun `creates complete config with all parameters`() { - val rotationDegrees = 180 - val result = runUseCaseTest(rotationDegrees) - assertThat(result).isEqualTo( - OcrCropConfig( - rotationDegrees = rotationDegrees, - cutoutRect = mockRect, - previewViewWidth = width800, - previewViewHeight = height600, - ), - ) - } - - @Test - fun `handles zero rotation degrees`() { - val result = runUseCaseTest(0) - assertThat(result.rotationDegrees).isEqualTo(0) - } - - @Test - fun `handles different preview dimensions`() { - val expectedWith = 1920 - val expectedHeight = 1080 - every { mockCameraPreview.width } returns expectedWith - every { mockCameraPreview.height } returns expectedHeight - - val result = runUseCaseTest() - - assertThat(result.previewViewWidth).isEqualTo(expectedWith) - assertThat(result.previewViewHeight).isEqualTo(expectedHeight) - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCaseTest.kt deleted file mode 100644 index f7be7808bf..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCaseTest.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Bitmap -import android.graphics.Rect -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import io.mockk.verify -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -internal class CropDocumentFromPreviewUseCaseTest { - @MockK - lateinit var sourceBitmap: Bitmap - - @MockK - lateinit var croppedBitmap: Bitmap - - private lateinit var useCase: CropDocumentFromPreviewUseCase - - private val bitmapWidth = 1080 - private val bitmapHeight = 1920 - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - every { sourceBitmap.width } returns bitmapWidth - every { sourceBitmap.height } returns bitmapHeight - - useCase = CropDocumentFromPreviewUseCase() - } - - @Test - fun `crops bitmap with valid cutout rect`() { - val cutoutRect = Rect(200, 300, 800, 1500) - - mockkStatic(Bitmap::class) - every { - Bitmap.createBitmap( - sourceBitmap, - cutoutRect.left, - cutoutRect.top, - cutoutRect.width(), - cutoutRect.height(), - ) - } returns croppedBitmap - - val result = useCase(sourceBitmap, cutoutRect) - - assertThat(result).isEqualTo(croppedBitmap) - verify { - Bitmap.createBitmap( - sourceBitmap, - cutoutRect.left, - cutoutRect.top, - cutoutRect.width(), - cutoutRect.height(), - ) - } - unmockkStatic(Bitmap::class) - } - - @Test - fun `clamps cutout rect that extends beyond bitmap bounds`() { - val cutoutRect = Rect(-100, -50, bitmapWidth + 200, bitmapHeight + 100) - val expectedLeft = 0 - val expectedTop = 0 - val expectedRight = bitmapWidth - val expectedBottom = bitmapHeight - - mockkStatic(Bitmap::class) - every { - Bitmap.createBitmap( - sourceBitmap, - expectedLeft, - expectedTop, - expectedRight - expectedLeft, - expectedBottom - expectedTop, - ) - } returns croppedBitmap - - val result = useCase(sourceBitmap, cutoutRect) - - assertThat(result).isEqualTo(croppedBitmap) - verify { - Bitmap.createBitmap( - sourceBitmap, - expectedLeft, - expectedTop, - expectedRight - expectedLeft, - expectedBottom - expectedTop, - ) - } - unmockkStatic(Bitmap::class) - } - - @Test - fun `handles cutout rect with negative coordinates`() { - val left = -100 - val top = -200 - val right = 500 - val bottom = 1000 - val expectedLeft = 0 - val expectedTop = 0 - val expectedWidth = right - expectedLeft - val expectedHeight = bottom - expectedTop - val cutoutRect = Rect(left, top, right, bottom) - - mockkStatic(Bitmap::class) - every { - Bitmap.createBitmap( - sourceBitmap, - expectedLeft, - expectedTop, - expectedWidth, - expectedHeight, - ) - } returns croppedBitmap - - val result = useCase(sourceBitmap, cutoutRect) - - assertThat(result).isEqualTo(croppedBitmap) - verify { - Bitmap.createBitmap( - sourceBitmap, - expectedLeft, - expectedTop, - expectedWidth, - expectedHeight, - ) - } - unmockkStatic(Bitmap::class) - } - - @Test - fun `handles cutout rect exceeding right and bottom bounds`() { - val left = 500 - val top = 1000 - val cutoutRect = Rect(left, top, bitmapWidth + 100, bitmapHeight + 200) - val expectedRight = bitmapWidth - val expectedBottom = bitmapHeight - val expectedWidth = expectedRight - left - val expectedHeight = expectedBottom - top - - mockkStatic(Bitmap::class) - every { - Bitmap.createBitmap( - sourceBitmap, - left, - top, - expectedWidth, - expectedHeight, - ) - } returns croppedBitmap - - val result = useCase(sourceBitmap, cutoutRect) - - assertThat(result).isEqualTo(croppedBitmap) - verify { - Bitmap.createBitmap( - sourceBitmap, - left, - top, - expectedWidth, - expectedHeight, - ) - } - unmockkStatic(Bitmap::class) - } - - @Test - fun `returns original bitmap when clamped rect produces zero width`() { - // left and right both clamp to bitmapWidth, yielding width=0 - val cutoutRect = Rect(bitmapWidth + 100, 0, bitmapWidth + 500, bitmapHeight) - val result = useCase(sourceBitmap, cutoutRect) - assertThat(result).isEqualTo(sourceBitmap) - } - - @Test - fun `returns original bitmap when clamped rect produces zero height`() { - // top and bottom both clamp to bitmapHeight, yielding height=0 - val cutoutRect = Rect(0, bitmapHeight + 100, bitmapWidth, bitmapHeight + 500) - val result = useCase(sourceBitmap, cutoutRect) - assertThat(result).isEqualTo(sourceBitmap) - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCaseTest.kt deleted file mode 100644 index 468212b0d5..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCaseTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Bitmap -import android.graphics.Matrix -import android.graphics.Rect -import androidx.core.graphics.scale -import com.google.common.truth.Truth.* -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -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 NormalizeBitmapToPreviewUseCaseTest { - @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: NormalizeBitmapToPreviewUseCase - - 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 = NormalizeBitmapToPreviewUseCase() - } - - @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 { - val cropConfig = createCropConfig(rotationDegrees = 0) - - every { - Bitmap.createBitmap(inputBitmap, any(), any(), any(), any()) - } returns croppedBitmap - - val result = useCase(inputBitmap, cropConfig) - - 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 { - val cropConfig = createCropConfig(rotationDegrees) - - 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, cropConfig) - - assertThat(result).isEqualTo(scaledBitmap) - verify { Bitmap.createBitmap(inputBitmap, 0, 0, inputWidth, inputHeight, any(), true) } - verify { croppedBitmap.scale(previewWidth, previewHeight) } - } - - private fun createCropConfig(rotationDegrees: Int) = OcrCropConfig( - rotationDegrees = rotationDegrees, - cutoutRect = Rect(0, 0, 100, 100), - previewViewWidth = previewWidth, - previewViewHeight = previewHeight, - ) -}