diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index 510eb7728e..1dad11563d 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -20,4 +20,5 @@ dependencies { implementation(project(":infra:camera")) implementation(libs.mlkit.text.recognition) + implementation(libs.playServices.barcode) } 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 0f87ca2e2f..948e4095a7 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 @@ -195,6 +195,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex cameraPreviewView = binding.preview, target = targetRect, highResolution = ocrConfig.useHighRes, + keepHighResolutionDetail = ocrConfig.keepHighResDetail, ) { Simber.e("Camera binding failed in OCR", it, MULTI_FACTOR_ID) } 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 e1f3b7f701..df515c7973 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 @@ -14,6 +14,7 @@ import com.simprints.feature.externalcredential.screens.scanocr.model.LightingCo 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.OcrDocumentType +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrScanResult 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.GetLightingConditionsAssessmentConfigUseCase @@ -87,7 +88,9 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( with(configRepository.getProjectConfiguration().experimental()) { ocrConfigFlow.value = OcrConfig( useHighRes = ocrUseHighRes, + keepHighResDetail = ocrKeepHighResDetail, capturesRequired = ocrCaptures.coerceIn(OCR_CAPTURE_MIN, OCR_CAPTURE_MAX), + readFaydaBarcode = ocrReadFaydaBarcode, ) } lightingConditionsAssessmentConfig = getLightingConditionsAssessmentConfig() @@ -141,16 +144,23 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( if (!isOcrAllowed) return@launch val mfidConfig = configRepository.getProjectConfiguration().multifactorId ?: return@launch val captureConfig = ocrConfigFlow.value ?: return@launch - val scannedMfidDocument = - scanMfidDocumentUseCase(bitmap = cropped, documentType = ocrDocumentType, config = mfidConfig) ?: return@launch + val scannedMfidDocument = scanMfidDocumentUseCase( + bitmap = cropped, + documentType = ocrDocumentType, + config = mfidConfig, + readFaydaBarcode = captureConfig.readFaydaBarcode, + ) ?: return@launch Simber.d("Detected OCR") - val notEnoughCaptures = scannedMfidDocuments.size < captureConfig.capturesRequired - if (isScanningInProgress && notEnoughCaptures) { + if (isScanningInProgress && !isCaptureComplete(captureConfig)) { scannedMfidDocuments += scannedMfidDocument updateState { ScanOcrState.ScanningInProgress( ocrDocumentType = ocrDocumentType, - successfulCaptures = scannedMfidDocuments.size, + successfulCaptures = if (isCaptureComplete(captureConfig)) { + captureConfig.capturesRequired + } else { + scannedMfidDocuments.size + }, scansRequired = captureConfig.capturesRequired, ) } @@ -161,6 +171,11 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } } + private fun isCaptureComplete(captureConfig: OcrConfig) = scannedMfidDocuments.size >= captureConfig.capturesRequired || + // Barcode readouts are checksummed, so a single one completes the capture + captureConfig.readFaydaBarcode && + scannedMfidDocuments.any { (it.ocrScanResult as? OcrScanResult.FaydaCard)?.isFromBarcode == true } + fun processOcrResultsAndFinish() { updateState { ScanOcrState.Complete } viewModelScope.launch { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrConfig.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrConfig.kt index 314b03df9a..b6c9e0dd4d 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrConfig.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrConfig.kt @@ -5,5 +5,7 @@ import com.simprints.core.ExcludedFromGeneratedTestCoverageReports @ExcludedFromGeneratedTestCoverageReports("Data struct") internal data class OcrConfig( val useHighRes: Boolean, + val keepHighResDetail: Boolean, val capturesRequired: Int, + val readFaydaBarcode: Boolean, ) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrScanResult.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrScanResult.kt index 99fc3d7841..0e684c0cad 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrScanResult.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrScanResult.kt @@ -41,5 +41,6 @@ internal sealed class OcrScanResult { @SerialName("OcrScanResult.FaydaCard") data class FaydaCard( override val credential: OcrLine, + val isFromBarcode: Boolean = false, ) : OcrScanResult() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/ScannedBarcode.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/ScannedBarcode.kt new file mode 100644 index 0000000000..c9236f37fc --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/ScannedBarcode.kt @@ -0,0 +1,16 @@ +package com.simprints.feature.externalcredential.screens.scanocr.reader + +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.model.BoundingBox + +/** + * A single barcode detected in the scanned image. + * + * @param value decoded barcode value + * @param boundingBox coordinates of the barcode + */ +@ExcludedFromGeneratedTestCoverageReports("Data class") +internal data class ScannedBarcode( + val value: String, + val boundingBox: BoundingBox, +) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCase.kt index 38e00f4bfa..163c712f52 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCase.kt @@ -70,11 +70,19 @@ internal class BuildMfidDocumentUseCase @Inject constructor( } private fun buildFaydaCard(scannedDocuments: List): MfidDocument.FaydaCard { + // Barcode readouts are checksummed, so they are preferred over OCR readouts that may contain misread digits. The most frequent + // complete barcode value is used, as per-character voting across differing barcode values could produce a value never decoded. val credential = scannedDocuments + .filter { (it.ocrScanResult as? OcrScanResult.FaydaCard)?.isFromBarcode == true } .map { it.ocrScanResult.credential.text } - .let { credentials -> getBestReadoutBasedOnConfidenceUseCase(credentials, targetLength = CREDENTIAL_LENGTH_FAYDA_CARD) } - .asTokenizableRaw() - return MfidDocument.FaydaCard(credential = credential) + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + ?: scannedDocuments + .map { it.ocrScanResult.credential.text } + .let { credentials -> getBestReadoutBasedOnConfidenceUseCase(credentials, targetLength = CREDENTIAL_LENGTH_FAYDA_CARD) } + return MfidDocument.FaydaCard(credential = credential.asTokenizableRaw()) } private fun List.bestReadoutOrNull(ocrLine: (T) -> OcrLine?) = mapNotNull(ocrLine) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCase.kt index 98a1def045..341fe92e60 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCase.kt @@ -3,6 +3,7 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrScanResult import com.simprints.feature.externalcredential.screens.scanocr.model.ScannedMfidDocument import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import javax.inject.Inject @@ -23,7 +24,8 @@ internal class BuildScannedCredentialResultUseCase @Inject constructor( // transitioning to the next screen, as the image looks closest to the last frame the user saw val (latestImagePath, latestCredentialOcrBlock) = scannedDocuments.last().imagePath to scannedDocuments.last().ocrScanResult.credential - val zoomedCredentialImagePath = createAndSaveZoomedImageUseCase(latestCredentialOcrBlock, latestImagePath) + val isBarcode = (scannedDocuments.last().ocrScanResult as? OcrScanResult.FaydaCard)?.isFromBarcode == true + val zoomedCredentialImagePath = createAndSaveZoomedImageUseCase(latestCredentialOcrBlock, latestImagePath, isBarcode) return ScannedCredentialResult( document = document, diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CreateAndSaveZoomedImageUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CreateAndSaveZoomedImageUseCase.kt index d68e12b501..69af668e83 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CreateAndSaveZoomedImageUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CreateAndSaveZoomedImageUseCase.kt @@ -14,9 +14,10 @@ internal class CreateAndSaveZoomedImageUseCase @Inject constructor( suspend operator fun invoke( ocrLine: OcrLine, fullSizeImagePath: String, + isBarcode: Boolean = false, ) = try { credentialImageRepository.saveCredentialScan( - bitmap = zoomOntoCredentialUseCase(fullSizeImagePath, ocrLine.blockBoundingBox), + bitmap = zoomOntoCredentialUseCase(fullSizeImagePath, ocrLine.blockBoundingBox, isBarcode), imageType = ZoomedInCredential, ) } catch (e: Exception) { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCase.kt index 9a41260917..c3ab04e3e3 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCase.kt @@ -1,7 +1,9 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import com.simprints.feature.externalcredential.screens.scanocr.model.OcrScanResult +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader +import com.simprints.feature.externalcredential.screens.scanocr.reader.ScannedBarcode import javax.inject.Inject internal class FaydaCardOcrReaderUseCase @Inject constructor() { @@ -11,6 +13,22 @@ internal class FaydaCardOcrReaderUseCase @Inject constructor() { } } + /** + * Reads the FAN from the barcode printed next to it on the card. Barcode decoding is checksummed, so unlike OCR it does not + * misread individual digits, and it works at distances where the printed FAN is too small for OCR. + */ + fun fromBarcodes(barcodes: List): OcrScanResult.FaydaCard? { + val barcode = barcodes.firstOrNull { FAN_PATTERN.matches(it.value) } ?: return null + val credential = OcrLine( + id = BARCODE_LINE_ID, + text = barcode.value, + boundingBox = barcode.boundingBox, + blockBoundingBox = barcode.boundingBox, + confidence = 1f, + ) + return OcrScanResult.FaydaCard(credential, isFromBarcode = true) + } + companion object { // Fayda Alias Number (FAN): exactly 16 digits val FAN_PATTERN = Regex("^\\d{16}$") @@ -20,5 +38,7 @@ internal class FaydaCardOcrReaderUseCase @Inject constructor() { // Punctuation between digits and more than 4 groups are not allowed, so that adjacent dates such as // "07/02/1998 | 2005/10/17" are not read as a FAN, even if OCR reads the separators as spaces ("07 02 1998 2005 10 17"). val FAN_OCR_PATTERN = Regex("(? = try { + val image = InputImage.fromBitmap(bitmap, 0) + Tasks.await(scanner.process(image)).orEmpty().mapNotNull { barcode -> + val value = barcode.rawValue ?: return@mapNotNull null + val box = barcode.boundingBox ?: return@mapNotNull null + ScannedBarcode(value = value, boundingBox = box.toBoundingBox()) + } + } catch (e: Exception) { + Simber.i("Barcode detection failed", e, tag = MULTI_FACTOR_ID) + emptyList() + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt index cd874bac94..07cf9f9d18 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt @@ -15,6 +15,7 @@ import javax.inject.Singleton @Singleton internal class ScanMfidDocumentUseCase @Inject constructor( private val readTextFromImage: ReadTextFromImageUseCase, + private val readBarcodesFromImage: ReadBarcodesFromImageUseCase, private val ghanaNhisCardOcrReaderUseCase: GhanaNhisCardOcrReaderUseCase, private val ghanaIdCardOcrReaderUseCase: GhanaIdCardOcrReaderUseCase, private val faydaCardOcrReaderUseCase: FaydaCardOcrReaderUseCase, @@ -24,6 +25,7 @@ internal class ScanMfidDocumentUseCase @Inject constructor( bitmap: Bitmap, documentType: OcrDocumentType, config: MultiFactorIdConfiguration, + readFaydaBarcode: Boolean = false, ): ScannedMfidDocument? { val isCapturingAllFields = when (documentType) { OcrDocumentType.NhisCard -> config.nhisCardConfig?.isCapturingAllFields @@ -31,6 +33,13 @@ internal class ScanMfidDocumentUseCase @Inject constructor( OcrDocumentType.FaydaCard -> config.faydaCardConfig?.isCapturingAllFields } ?: false return try { + if (readFaydaBarcode && documentType == OcrDocumentType.FaydaCard) { + // Fayda cards carry the FAN in a barcode, which reads faster and more reliably than the printed number + faydaCardOcrReaderUseCase.fromBarcodes(readBarcodesFromImage(bitmap))?.let { scanResult -> + val savedImagePath = credentialImageRepository.saveCredentialScan(bitmap, imageType = FullDocument) + return ScannedMfidDocument(savedImagePath, scanResult) + } + } val ocrText = readTextFromImage(bitmap) ?: return null val ocrReader = OcrReader(ocrText) val scanResult = when (documentType) { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt index ac7c11bea4..5616b429d0 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt @@ -13,14 +13,17 @@ internal class ZoomOntoCredentialUseCase @Inject constructor() { * * @param imagePath path to image containing the document to zoom into * @param boundingBox bounding box that defines the zoom area + * @param isBarcode whether [boundingBox] is a barcode. Barcode boxes only cover the bars, so a wider margin is used to include + * the human-readable number printed next to them, which can be wider than the barcode. * @return zoomed-in bitmap */ operator fun invoke( imagePath: String, boundingBox: BoundingBox, + isBarcode: Boolean = false, ): Bitmap { val bitmap = BitmapFactory.decodeFile(imagePath) - val expandedBox = scaleBoundingBox(boundingBox, BOX_SCALE_FACTOR) + val expandedBox = scaleBoundingBox(boundingBox, if (isBarcode) BARCODE_BOX_SCALE_FACTOR else BOX_SCALE_FACTOR) val left = expandedBox.left.coerceIn(0, bitmap.width) val top = expandedBox.top.coerceIn(0, bitmap.height) @@ -83,5 +86,6 @@ internal class ZoomOntoCredentialUseCase @Inject constructor() { companion object { private const val TARGET_ASPECT_RATIO = 16f / 10f private const val BOX_SCALE_FACTOR = 1.15f + private const val BARCODE_BOX_SCALE_FACTOR = 1.3f } } 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 1532bbff89..17e90f6fe8 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 @@ -8,6 +8,7 @@ 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.OcrDocumentType +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrScanResult 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.GetLightingConditionsAssessmentConfigUseCase @@ -18,6 +19,7 @@ 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.ExperimentalProjectConfiguration.Companion.OCR_READ_FAYDA_BARCODE import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.resources.R import com.simprints.testtools.common.coroutines.TestCoroutineRule @@ -134,6 +136,33 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(viewModel.isOcrActive).isTrue() } + @Test + fun `processImage passes Fayda barcode flag from config to scan use case`() = runTest { + val mockCroppedBitmap = mockk() + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap + viewModel = initViewModel( + documentType = OcrDocumentType.FaydaCard, + customConfig = mapOf(OCR_READ_FAYDA_BARCODE to JsonPrimitive(true)), + ) + + viewModel.startScanning() + viewModel.processImage(frame) + + coVerify { scanMfidDocumentUseCase(mockCroppedBitmap, OcrDocumentType.FaydaCard, any(), readFaydaBarcode = true) } + } + + @Test + fun `processImage does not read Fayda barcode by default`() = runTest { + val mockCroppedBitmap = mockk() + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap + viewModel = initViewModel(documentType = OcrDocumentType.FaydaCard) + + viewModel.startScanning() + viewModel.processImage(frame) + + coVerify { scanMfidDocumentUseCase(mockCroppedBitmap, OcrDocumentType.FaydaCard, any(), readFaydaBarcode = false) } + } + @Test fun `processImage updates lighting conditions after debounce`() = runTest { val mockCroppedBitmap = mockk() @@ -171,7 +200,7 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(observer.value()).isInstanceOf(ScanOcrState.NotScanning::class.java) coVerify(exactly = 0) { frameCropToTargetUseCase(frame) } - coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any(), any()) } coVerify(exactly = 0) { getLightingConditionsAssessment.invoke(any(), any()) } assertThat(viewModel.isOcrActive).isFalse() } @@ -200,7 +229,7 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(observer.value()).isEqualTo(lightingConditionsAssessment) coVerify(exactly = 1) { getLightingConditionsAssessment(mockCroppedBitmap, any()) } - coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any(), any()) } } @Test @@ -355,6 +384,58 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(completionTriggers).isEqualTo(1) } + @Test + fun `processImage completes capture after a single Fayda barcode readout`() = runTest { + val barcodeDocument = ScannedMfidDocument( + imagePath = "path", + ocrScanResult = OcrScanResult.FaydaCard(credential = mockk(), isFromBarcode = true), + ) + val mockCroppedBitmap = mockk() + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap + viewModel = initViewModel( + documentType = OcrDocumentType.FaydaCard, + customConfig = mapOf(OCR_READ_FAYDA_BARCODE to JsonPrimitive(true)), + ) + coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, OcrDocumentType.FaydaCard, any(), any()) } returns barcodeDocument + + val observer = viewModel.scanOcrStateLiveData.test() + viewModel.startScanning() + viewModel.processImage(frame) + + val state = observer.value() as ScanOcrState.ScanningInProgress + assertThat(state.successfulCaptures).isEqualTo(state.scansRequired) + + repeat(2) { viewModel.processImage(frame) } + val completionTriggers = observer + .valueHistory() + .filterIsInstance() + .count { it.successfulCaptures >= it.scansRequired } + assertThat(completionTriggers).isEqualTo(1) + } + + @Test + fun `processImage requires all captures for Fayda OCR readouts when barcode reading is enabled`() = runTest { + val ocrDocument = ScannedMfidDocument( + imagePath = "path", + ocrScanResult = OcrScanResult.FaydaCard(credential = mockk(), isFromBarcode = false), + ) + val mockCroppedBitmap = mockk() + coEvery { frameCropToTargetUseCase(frame) } returns mockCroppedBitmap + viewModel = initViewModel( + documentType = OcrDocumentType.FaydaCard, + customConfig = mapOf(OCR_READ_FAYDA_BARCODE to JsonPrimitive(true)), + ) + coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, OcrDocumentType.FaydaCard, any(), any()) } returns ocrDocument + + val observer = viewModel.scanOcrStateLiveData.test() + viewModel.startScanning() + viewModel.processImage(frame) + + val state = observer.value() as ScanOcrState.ScanningInProgress + assertThat(state.successfulCaptures).isEqualTo(1) + assertThat(state.successfulCaptures).isLessThan(state.scansRequired) + } + @Test fun `processImage does not append documents or update state after scanning is complete`() = runTest { val mockScannedDocument = mockk() @@ -377,6 +458,6 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(stateObserver.value()).isEqualTo(ScanOcrState.Complete) assertThat(viewModel.isOcrActive).isFalse() - coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any(), any()) } } } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCaseTest.kt index 589fc33a95..33bba9b713 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCaseTest.kt @@ -187,6 +187,38 @@ internal class BuildMfidDocumentUseCaseTest { verify { getBestReadout(listOf(credentialText), targetLength = FAYDA_CREDENTIAL_LENGTH) } } + @Test + fun `uses only barcode readouts for FaydaCard when available`() { + val documents = listOf( + faydaScannedDocument(credential = "8642370540168184"), + faydaScannedDocument(credential = "8642370540168134", isFromBarcode = true), + faydaScannedDocument(credential = "8642370540168184"), + ) + val result = useCase(documents, OcrDocumentType.FaydaCard) as MfidDocument.FaydaCard + assertThat(result.credential).isEqualTo("8642370540168134".asTokenizableRaw()) + verify(exactly = 0) { getBestReadout(any(), any()) } + } + + @Test + fun `uses most frequent complete barcode value for FaydaCard`() { + val documents = listOf( + faydaScannedDocument(credential = "2580945924815610", isFromBarcode = true), + faydaScannedDocument(credential = "2580945924815010", isFromBarcode = true), + faydaScannedDocument(credential = "2580945924815610", isFromBarcode = true), + ) + val result = useCase(documents, OcrDocumentType.FaydaCard) as MfidDocument.FaydaCard + assertThat(result.credential).isEqualTo("2580945924815610".asTokenizableRaw()) + } + + @Test + fun `returns one of the decoded barcode values when all barcode readouts differ`() { + val barcodeValues = listOf("2580945924815100", "2580945924815010", "2580945924815001") + val documents = barcodeValues.map { faydaScannedDocument(credential = it, isFromBarcode = true) } + val result = useCase(documents, OcrDocumentType.FaydaCard) as MfidDocument.FaydaCard + assertThat(result.credential).isIn(barcodeValues.map { it.asTokenizableRaw() }) + verify(exactly = 0) { getBestReadout(any(), any()) } + } + @Test fun `sets credential from best readout for FaydaCard`() { val expectedCredential = "1234567812345678" @@ -233,10 +265,14 @@ internal class BuildMfidDocumentUseCaseTest { verify { getBestReadout(texts, targetLength = NHIS_CREDENTIAL_LENGTH) } } - private fun faydaScannedDocument(credential: String = "1234567812345678") = ScannedMfidDocument( + private fun faydaScannedDocument( + credential: String = "1234567812345678", + isFromBarcode: Boolean = false, + ) = ScannedMfidDocument( imagePath = "path/to/image.jpg", ocrScanResult = OcrScanResult.FaydaCard( credential = ocrLine(credential), + isFromBarcode = isFromBarcode, ), ) diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCaseTest.kt index ca092a949e..527fdb2595 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCaseTest.kt @@ -103,6 +103,19 @@ internal class BuildScannedCredentialResultUseCaseTest { coVerify { createAndSaveZoomedImageUseCase(lastDocument.ocrScanResult.credential, lastFile) } } + @Test + fun `zoomed image uses barcode margin when last document was read from a barcode`() = runTest { + val lastFile = "last.jpg" + val barcodeDocument = ScannedMfidDocument( + imagePath = lastFile, + ocrScanResult = OcrScanResult.FaydaCard(credential = ocrLine("1234567812345678", null), isFromBarcode = true), + ) + + useCase(listOf(nhisScannedDocument(imagePath = "first.jpg"), barcodeDocument), OcrDocumentType.FaydaCard, startTime) + + coVerify { createAndSaveZoomedImageUseCase(barcodeDocument.ocrScanResult.credential, lastFile, isBarcode = true) } + } + @Test fun `result zoomed image path comes from createAndSaveZoomedImageUseCase`() = runTest { val documents = listOf(nhisScannedDocument()) diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCaseTest.kt index e3a6e5f1b2..4a777330f8 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FaydaCardOcrReaderUseCaseTest.kt @@ -5,6 +5,7 @@ import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrText +import com.simprints.feature.externalcredential.screens.scanocr.reader.ScannedBarcode import io.mockk.MockKAnnotations import org.junit.Before import org.junit.Test @@ -105,6 +106,52 @@ internal class FaydaCardOcrReaderUseCaseTest { assertThat(result?.credential?.text).isEqualTo("8642370540168134") } + @Test + fun `returns FAN from barcode`() { + val barcodes = listOf(barcode("2580945924815610", top = 100, bottom = 140)) + + val result = useCase.fromBarcodes(barcodes) + + assertThat(result?.credential?.text).isEqualTo("2580945924815610") + assertThat(result?.credential?.confidence).isEqualTo(1f) + assertThat(result?.isFromBarcode).isTrue() + } + + @Test + fun `uses barcode box as credential box`() { + val result = useCase.fromBarcodes(listOf(barcode("2580945924815610", top = 100, bottom = 140))) + + assertThat(result?.credential?.boundingBox).isEqualTo(BoundingBox(left = 10, top = 100, right = 200, bottom = 140)) + assertThat(result?.credential?.blockBoundingBox).isEqualTo(result?.credential?.boundingBox) + } + + @Test + fun `skips barcodes that are not a FAN`() { + val barcodes = listOf( + barcode("ABC123", top = 0, bottom = 10), + barcode("123456781234567", top = 0, bottom = 10), // 15 digits + barcode("1234 5678 1234 5678", top = 0, bottom = 10), // separators are not expected in barcodes + barcode("2580945924815610", top = 100, bottom = 140), + ) + + assertThat(useCase.fromBarcodes(barcodes)?.credential?.text).isEqualTo("2580945924815610") + } + + @Test + fun `returns null when no barcode is a FAN`() { + assertThat(useCase.fromBarcodes(emptyList())).isNull() + assertThat(useCase.fromBarcodes(listOf(barcode("12345678123456789", top = 0, bottom = 10)))).isNull() + } + + private fun barcode( + value: String, + top: Int, + bottom: Int, + ) = ScannedBarcode( + value = value, + boundingBox = BoundingBox(left = 10, top = top, right = 200, bottom = bottom), + ) + private fun buildReader(vararg lines: OcrLine) = OcrReader( OcrText(allLines = lines.toList()), ) diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCaseTest.kt index 50c49e1f28..2c4857ffbc 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCaseTest.kt @@ -6,6 +6,7 @@ import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.model.OcrScanResult import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.scanocr.reader.ScannedBarcode import com.simprints.infra.config.store.models.FaydaCardConfig import com.simprints.infra.config.store.models.GhanaIdCardConfig import com.simprints.infra.config.store.models.MultiFactorIdConfiguration @@ -26,6 +27,9 @@ internal class ScanMfidDocumentUseCaseTest { @MockK private lateinit var readTextFromImage: ReadTextFromImageUseCase + @MockK + private lateinit var readBarcodesFromImage: ReadBarcodesFromImageUseCase + @MockK private lateinit var ghanaNhisCardOcrReaderUseCase: GhanaNhisCardOcrReaderUseCase @@ -76,12 +80,15 @@ internal class ScanMfidDocumentUseCaseTest { MockKAnnotations.init(this, relaxed = true) useCase = ScanMfidDocumentUseCase( readTextFromImage = readTextFromImage, + readBarcodesFromImage = readBarcodesFromImage, ghanaNhisCardOcrReaderUseCase = ghanaNhisCardOcrReaderUseCase, ghanaIdCardOcrReaderUseCase = ghanaIdCardOcrReaderUseCase, faydaCardOcrReaderUseCase = faydaCardOcrReaderUseCase, credentialImageRepository = credentialImageRepository, ) coEvery { credentialImageRepository.saveCredentialScan(any(), any()) } returns savedImagePath + every { readBarcodesFromImage(any()) } returns emptyList() + every { faydaCardOcrReaderUseCase.fromBarcodes(any()) } returns null } @Test @@ -164,6 +171,53 @@ internal class ScanMfidDocumentUseCaseTest { verify(exactly = 0) { ghanaIdCardOcrReaderUseCase(any(), any()) } } + @Test + fun `returns FaydaCard from barcode without running OCR`() = runTest { + val barcodes = listOf(ScannedBarcode(value = "1234567812345678", boundingBox = BoundingBox(0, 0, 10, 10))) + val barcodeResult = OcrScanResult.FaydaCard(credential = ocrLine(text = "1234567812345678")) + every { readBarcodesFromImage(bitmap) } returns barcodes + every { faydaCardOcrReaderUseCase.fromBarcodes(barcodes) } returns barcodeResult + + val result = useCase(bitmap, OcrDocumentType.FaydaCard, faydaConfig, readFaydaBarcode = true) + + assertThat(result?.ocrScanResult).isEqualTo(barcodeResult) + verify(exactly = 0) { readTextFromImage(any()) } + verify(exactly = 0) { faydaCardOcrReaderUseCase(any()) } + } + + @Test + fun `falls back to OCR for FaydaCard when barcode is not read`() = runTest { + val ocrResult = OcrScanResult.FaydaCard(credential = ocrLine(text = "1234567812345678")) + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { faydaCardOcrReaderUseCase(any()) } returns ocrResult + + val result = useCase(bitmap, OcrDocumentType.FaydaCard, faydaConfig, readFaydaBarcode = true) + + assertThat(result?.ocrScanResult).isEqualTo(ocrResult) + verify(exactly = 1) { readBarcodesFromImage(bitmap) } + verify(exactly = 1) { readTextFromImage(bitmap) } + } + + @Test + fun `does not read barcodes for Ghana cards`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig, readFaydaBarcode = true) + useCase(bitmap, OcrDocumentType.GhanaIdCard, ghanaIdConfig, readFaydaBarcode = true) + + verify(exactly = 0) { readBarcodesFromImage(any()) } + } + + @Test + fun `does not read barcodes for FaydaCard when disabled`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.FaydaCard, faydaConfig, readFaydaBarcode = false) + + verify(exactly = 0) { readBarcodesFromImage(any()) } + verify(exactly = 1) { faydaCardOcrReaderUseCase(any()) } + } + @Test fun `saves image when line is found`() = runTest { every { readTextFromImage(bitmap) } returns mockk(relaxed = true) diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCaseTest.kt index 47f2bec24b..ba7c19b88b 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCaseTest.kt @@ -76,6 +76,24 @@ class ZoomOntoCredentialUseCaseTest { runEdgeClampingTest(boundingBox) } + @Test + fun `uses a wider margin for barcode bounding boxes`() { + val boundingBox = BoundingBox(left = 600, top = 450, right = 1000, bottom = 550) + val capturedWidths = mutableListOf() + mockkStatic(Bitmap::class) + every { + Bitmap.createBitmap(any(), any(), any(), capture(capturedWidths), any()) + } returns mockk() + + useCase(imagePath, boundingBox) + useCase(imagePath, boundingBox, isBarcode = true) + + val (defaultWidth, barcodeWidth) = capturedWidths + assertThat(barcodeWidth).isGreaterThan(defaultWidth) + // Barcode margin covers a printed number up to ~30% wider than the barcode + assertThat(barcodeWidth).isAtLeast((boundingBox.right - boundingBox.left) * 129 / 100) + } + @Test fun `handles bounding box in center of image correctly`() { val boundingBox = BoundingBox(left = 400, top = 400, right = 600, bottom = 500) diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt index e3ebbb9614..ef2bd67506 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/CameraFrameProvider.kt @@ -70,6 +70,11 @@ class CameraFrameProvider @Inject internal constructor( private val frameEmissionHelper = FrameEmissionHelper() + // When true, high-resolution captures are not scaled down to the preview size. + // Set on the background dispatcher during initialisation and read on the camera executor. + @Volatile + private var keepHighResolutionDetail = false + fun isInitialised() = camera != null /** @@ -88,8 +93,10 @@ class CameraFrameProvider @Inject internal constructor( cameraPreviewView: CameraPreviewView, target: Rect? = null, highResolution: Boolean = false, + keepHighResolutionDetail: Boolean = false, onError: (Throwable) -> Unit = {}, ) = withContext(bgDispatcher) { + this@CameraFrameProvider.keepHighResolutionDetail = keepHighResolutionDetail val previewView = cameraPreviewView.previewView try { previewRect = fullPreviewSizeRect(previewView) @@ -216,6 +223,7 @@ class CameraFrameProvider @Inject internal constructor( imageCapture = null camera = null frameEmissionHelper.reset() + keepHighResolutionDetail = false if (!executor.isShutdown) executor.shutdown() } @@ -279,6 +287,7 @@ class CameraFrameProvider @Inject internal constructor( rotationDegrees, previewViewWidth, previewViewHeight, + scaleToPreview = !keepHighResolutionDetail, ), rotationDegrees, ) diff --git a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt index 9624558bdb..d7d752cd4a 100644 --- a/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt +++ b/infra/camera/src/main/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCase.kt @@ -18,6 +18,8 @@ internal class NormalizeHighResBitmapToPreviewUseCase @Inject constructor() { * sees in the camera preview, making the target bounds spatially consistent with the preview overlay. * * @param originalBitmap the original camera capture bitmap + * @param scaleToPreview when false, step 3 is skipped and the bitmap keeps the capture's native resolution (only the aspect + * ratio matches the PreviewView). Target bounds are still mapped correctly by FrameCropToTargetUseCase. * * @return a new bitmap with normalized dimensions and aspect ratio */ @@ -26,6 +28,7 @@ internal class NormalizeHighResBitmapToPreviewUseCase @Inject constructor() { rotationDegrees: Int, previewWidth: Int, previewHeight: Int, + scaleToPreview: Boolean = true, ): Bitmap { // Rotate if necessary val rotated = if (rotationDegrees != 0) { @@ -57,6 +60,7 @@ internal class NormalizeHighResBitmapToPreviewUseCase @Inject constructor() { } val cropped = Bitmap.createBitmap(rotated, offsetX, offsetY, cropWidth, cropHeight) + if (!scaleToPreview) return cropped // Scale to PreviewView size return cropped.scale(previewWidth, previewHeight) diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt index 8ad0a7879f..7ca5b92502 100644 --- a/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt +++ b/infra/camera/src/test/java/com/simprints/infra/camera/postprocess/FrameCropToTargetUseCaseTest.kt @@ -114,6 +114,22 @@ internal class FrameCropToTargetUseCaseTest { assertThat(cropped.height).isEqualTo(600) } + @Test + fun `Crops at native resolution when high-res bitmap has preview aspect ratio`() { + val cropped = useCase( + Frame( + bitmap = Bitmap.createBitmap(2160, 4062, Bitmap.Config.ARGB_8888), // 3x preview size + rotation = 0, + previewBounds = Rect(0, 0, 720, 1354), + targetBounds = Rect(28, 470, 692, 885), // 664x415 + ), + ) + + // Cropped should keep the 3x detail of the high-res capture + assertThat(cropped.width).isEqualTo(1992) + assertThat(cropped.height).isEqualTo(1245) + } + @Test fun `Correctly crops bitmap with valid cutout rectangle`() { val cropped = useCase( diff --git a/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt index 11805f0411..55a1e8e25e 100644 --- a/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt +++ b/infra/camera/src/test/java/com/simprints/infra/camera/usecase/NormalizeHighResBitmapToPreviewUseCaseTest.kt @@ -97,4 +97,22 @@ internal class NormalizeHighResBitmapToPreviewUseCaseTest { verify { Bitmap.createBitmap(inputBitmap, 0, 0, inputWidth, inputHeight, any(), true) } verify { croppedBitmap.scale(previewWidth, previewHeight) } } + + @Test + fun `keeps native resolution when scaling to preview is disabled`() = runTest { + every { + Bitmap.createBitmap(inputBitmap, 0, 0, inputWidth, inputHeight, any(), true) + } returns rotatedBitmap + + every { + Bitmap.createBitmap(rotatedBitmap, any(), any(), any(), any()) + } returns croppedBitmap + + val result = useCase(inputBitmap, rotationDegrees, previewWidth, previewHeight, scaleToPreview = false) + + Truth.assertThat(result).isEqualTo(croppedBitmap) + // Rotated 1080x1920 cropped to 4:3 at native resolution: 1080x810, centered vertically + verify { Bitmap.createBitmap(rotatedBitmap, 0, 555, 1080, 810) } + verify(exactly = 0) { croppedBitmap.scale(any(), any()) } + } } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index a832cff387..c0867742cb 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -59,6 +59,13 @@ data class ExperimentalProjectConfiguration( ?.booleanOrNull ?: OCR_USE_HIGH_RES_DEFAULT + val ocrKeepHighResDetail: Boolean + get() = customConfig + ?.get(OCR_KEEP_HIGH_RES_DETAIL) + ?.jsonPrimitive + ?.booleanOrNull + ?: OCR_KEEP_HIGH_RES_DETAIL_DEFAULT + val ocrCaptures: Int get() = customConfig ?.get(OCR_CAPTURES) @@ -66,6 +73,13 @@ data class ExperimentalProjectConfiguration( ?.intOrNull ?: OCR_CAPTURES_DEFAULT + val ocrReadFaydaBarcode: Boolean + get() = customConfig + ?.get(OCR_READ_FAYDA_BARCODE) + ?.jsonPrimitive + ?.booleanOrNull + ?: OCR_READ_FAYDA_BARCODE_DEFAULT + val allowConfirmingGuidsNotInCallback: Boolean get() = customConfig ?.get(ALLOW_CONFIRMING_GUIDS_NOT_IN_CALLBACK) @@ -252,8 +266,12 @@ data class ExperimentalProjectConfiguration( internal const val OCR_USE_HIGH_RES = "ocrHighRes" internal const val OCR_USE_HIGH_RES_DEFAULT = true + internal const val OCR_KEEP_HIGH_RES_DETAIL = "ocrKeepHighResDetail" + internal const val OCR_KEEP_HIGH_RES_DETAIL_DEFAULT = false internal const val OCR_CAPTURES = "ocrCaptures" internal const val OCR_CAPTURES_DEFAULT = 3 + const val OCR_READ_FAYDA_BARCODE = "ocrReadFaydaBarcode" + internal const val OCR_READ_FAYDA_BARCODE_DEFAULT = false const val ALLOW_CONFIRMING_GUIDS_NOT_IN_CALLBACK = "allowConfirmingGuidsNotInCallback" diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index e95818ef80..d4bfc24f95 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -137,6 +137,18 @@ internal class ExperimentalProjectConfigurationTest { } } + @Test + fun `check ocr keep high res detail flag correctly`() { + mapOf( + emptyMap() to false, + mapOf(ExperimentalProjectConfiguration.OCR_KEEP_HIGH_RES_DETAIL to JsonPrimitive(1)) to false, + mapOf(ExperimentalProjectConfiguration.OCR_KEEP_HIGH_RES_DETAIL to JsonPrimitive(false)) to false, + mapOf(ExperimentalProjectConfiguration.OCR_KEEP_HIGH_RES_DETAIL to JsonPrimitive(true)) to true, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).ocrKeepHighResDetail).isEqualTo(result) + } + } + @Test fun `check ocr captures value correctly`() { val expectedOcrCaptures = 10 @@ -150,6 +162,18 @@ internal class ExperimentalProjectConfigurationTest { } } + @Test + fun `check ocr read fayda barcode flag correctly`() { + mapOf( + emptyMap() to false, + mapOf(ExperimentalProjectConfiguration.OCR_READ_FAYDA_BARCODE to JsonPrimitive(1)) to false, + mapOf(ExperimentalProjectConfiguration.OCR_READ_FAYDA_BARCODE to JsonPrimitive(false)) to false, + mapOf(ExperimentalProjectConfiguration.OCR_READ_FAYDA_BARCODE to JsonPrimitive(true)) to true, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).ocrReadFaydaBarcode).isEqualTo(result) + } + } + @Test fun `check allow confirming GUIDs not in callback flag correctly`() { mapOf(