Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions feature/external-credential/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ dependencies {
implementation(project(":infra:camera"))

implementation(libs.mlkit.text.recognition)
implementation(libs.playServices.barcode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,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()
Expand Down Expand Up @@ -141,8 +143,12 @@ 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,6 @@ internal sealed class OcrScanResult {
@SerialName("OcrScanResult.FaydaCard")
data class FaydaCard(
override val credential: OcrLine,
val isFromBarcode: Boolean = false,
) : OcrScanResult()
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,19 @@ internal class BuildMfidDocumentUseCase @Inject constructor(
}

private fun buildFaydaCard(scannedDocuments: List<ScannedMfidDocument>): 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 <T> List<T>.bestReadoutOrNull(ocrLine: (T) -> OcrLine?) = mapNotNull(ocrLine)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.simprints.feature.externalcredential.screens.scanocr.usecase

import com.simprints.feature.externalcredential.model.BoundingBox
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() {
Expand All @@ -11,6 +14,31 @@ internal class FaydaCardOcrReaderUseCase @Inject constructor() {
}
}

/**
* Reads the FAN from the barcode printed below 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<ScannedBarcode>): OcrScanResult.FaydaCard? {
val barcode = barcodes.firstOrNull { FAN_PATTERN.matches(it.value) } ?: return null
// The printed FAN sits directly above the barcode. Expanding the box upwards so that the zoomed-in preview shown to the
// user includes the human-readable number as well.
val box = barcode.boundingBox
val expandedBox = BoundingBox(
left = box.left,
top = box.top - (box.bottom - box.top) / 2,
right = box.right,
bottom = box.bottom,
)
val credential = OcrLine(
id = BARCODE_LINE_ID,
text = barcode.value,
boundingBox = expandedBox,
blockBoundingBox = expandedBox,
confidence = 1f,
)
return OcrScanResult.FaydaCard(credential, isFromBarcode = true)
}

companion object {
// Fayda Alias Number (FAN): exactly 16 digits
val FAN_PATTERN = Regex("^\\d{16}$")
Expand All @@ -20,5 +48,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("(?<!\\d\\s{0,2})(?=(?:\\d+\\s{1,2}){0,3}\\d+(?!\\s{0,2}\\d))\\d(?:\\s{0,2}\\d){15}(?!\\s{0,2}\\d)")

private const val BARCODE_LINE_ID = -1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.simprints.feature.externalcredential.screens.scanocr.usecase

import android.graphics.Bitmap
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.feature.externalcredential.model.toBoundingBox
import com.simprints.feature.externalcredential.screens.scanocr.reader.ScannedBarcode
import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID
import com.simprints.infra.logging.Simber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
@ExcludedFromGeneratedTestCoverageReports("Unable to mock Google ML Kit")
internal class ReadBarcodesFromImageUseCase @Inject constructor() {
// Fayda cards carry the FAN in a Code 128 barcode. Created on first use, as only Fayda scans read barcodes.
private val scanner by lazy {
BarcodeScanning.getClient(
BarcodeScannerOptions
.Builder()
.setBarcodeFormats(Barcode.FORMAT_CODE_128)
.build(),
)
}

/**
* Returns the barcodes detected in [bitmap], or an empty list if detection fails (e.g. the Play Services barcode module is not
* available yet), so that callers can fall back to OCR.
*/
operator fun invoke(bitmap: Bitmap): List<ScannedBarcode> = 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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,13 +25,21 @@ internal class ScanMfidDocumentUseCase @Inject constructor(
bitmap: Bitmap,
documentType: OcrDocumentType,
config: MultiFactorIdConfiguration,
readFaydaBarcode: Boolean = false,
): ScannedMfidDocument? {
val isCapturingAllFields = when (documentType) {
OcrDocumentType.NhisCard -> config.nhisCardConfig?.isCapturingAllFields
OcrDocumentType.GhanaIdCard -> config.ghanaIdCardConfig?.isCapturingAllFields
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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
Expand Down Expand Up @@ -134,6 +135,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<Bitmap>()
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<Bitmap>()
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<Bitmap>()
Expand Down Expand Up @@ -171,7 +199,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()
}
Expand Down Expand Up @@ -200,7 +228,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
Expand Down Expand Up @@ -377,6 +405,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()) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
),
)

Expand Down
Loading
Loading