diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt index d4bca49638..62104e8926 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt @@ -5,8 +5,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.simprints.core.domain.externalcredential.ExternalCredential -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.livedata.LiveDataEvent import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send @@ -24,9 +22,6 @@ import com.simprints.feature.clientapi.usecases.GetEnrolmentCreationEventForReco import com.simprints.feature.clientapi.usecases.IsFlowCompletedWithErrorUseCase import com.simprints.feature.clientapi.usecases.SimpleEventReporter import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber import com.simprints.infra.orchestration.data.ActionRequest @@ -35,7 +30,6 @@ import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.infra.orchestration.data.responses.AppConfirmationResponse import com.simprints.infra.orchestration.data.responses.AppEnrolResponse import com.simprints.infra.orchestration.data.responses.AppErrorResponse -import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse import com.simprints.infra.orchestration.data.responses.AppRefusalResponse import com.simprints.infra.orchestration.data.responses.AppVerifyResponse @@ -58,7 +52,6 @@ class ClientApiViewModel @Inject internal constructor( private val configRepository: ConfigRepository, private val timeHelper: TimeHelper, private val persistentLogger: PersistentLogger, - private val tokenizationProcessor: TokenizationProcessor, ) : ViewModel() { val returnResponse: LiveData> get() = _returnResponse @@ -121,7 +114,7 @@ class ClientApiViewModel @Inject internal constructor( sessionId = currentSessionId, enrolledGuid = enrolResponse.guid, subjectActions = coSyncEnrolmentRecords, - externalCredential = enrolResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), + externalCredential = enrolResponse.externalCredential, ), ), ) @@ -170,7 +163,7 @@ class ClientApiViewModel @Inject internal constructor( actionIdentifier = action.actionIdentifier, sessionId = currentSessionId, confirmed = confirmResponse.identificationOutcome, - externalCredential = confirmResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), + externalCredential = confirmResponse.externalCredential, ), ), ) @@ -272,21 +265,4 @@ class ClientApiViewModel @Inject internal constructor( body = "${action.actionIdentifier}\n$response", ) } - - private fun ExternalCredential.toAppExternalCredential( - tokenizationProcessor: TokenizationProcessor, - project: Project?, - ): AppExternalCredential? { - if (project == null) return null - val decryptedValue = tokenizationProcessor.decrypt( - encrypted = value, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as? TokenizableString.Raw ?: return null - return AppExternalCredential( - id = id, - value = decryptedValue, - type = type, - ) - } } diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt index c38c020e35..1e20b37a28 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt @@ -16,6 +16,7 @@ import com.simprints.libsimprints.contracts.data.Identification.Companion.toJson import com.simprints.libsimprints.contracts.data.RefusalForm import com.simprints.libsimprints.contracts.data.ScannedCredential import com.simprints.libsimprints.contracts.data.Verification +import org.json.JSONObject import javax.inject.Inject import com.simprints.libsimprints.Identification as LegacyIdentification import com.simprints.libsimprints.RefusalForm as LegacyRefusalForm @@ -165,10 +166,14 @@ internal class LibSimprintsResponseMapper @Inject constructor( private fun Bundle.appendExternalCredential(credential: AppExternalCredential?) = apply { credential?.let { - putString( - Constants.SIMPRINTS_SCANNED_CREDENTIAL, - ScannedCredential(it.type.name, it.value.value).toJson(), - ) + val credentialJson = JSONObject(ScannedCredential(it.type.name, it.value.value).toJson()) + .apply { + val documentFields = JSONObject().apply { + it.nonCredentialFields.forEach { (key, value) -> put(key, value) } + } + put(SCANNED_CREDENTIAL_DOCUMENT_FIELDS, documentFields) + }.toString() + putString(Constants.SIMPRINTS_SCANNED_CREDENTIAL, credentialJson) } } @@ -208,5 +213,6 @@ internal class LibSimprintsResponseMapper @Inject constructor( companion object { internal const val RESULT_CODE_OVERRIDE = "result_code_override" + internal const val SCANNED_CREDENTIAL_DOCUMENT_FIELDS = "documentFields" } } diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt index ecee946617..5b7ed1b6c2 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt @@ -4,11 +4,9 @@ import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.assertThat import com.jraska.livedata.test -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.clientapi.exceptions.InvalidRequestException @@ -21,13 +19,12 @@ import com.simprints.feature.clientapi.usecases.GetEnrolmentCreationEventForReco import com.simprints.feature.clientapi.usecases.IsFlowCompletedWithErrorUseCase import com.simprints.feature.clientapi.usecases.SimpleEventReporter import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.orchestration.data.ActionRequest import com.simprints.infra.orchestration.data.ActionRequestIdentifier import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.infra.orchestration.data.responses.AppEnrolResponse +import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.logging.persistent.PersistentLogger import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* @@ -109,7 +106,6 @@ internal class ClientApiViewModelTest { configRepository = configRepository, timeHelper = timeHelper, persistentLogger = persistentLogger, - tokenizationProcessor = tokenizationProcessor, ) } @@ -276,49 +272,24 @@ internal class ClientApiViewModelTest { } @Test - fun `handleEnrolResponse with externalCredential decrypts and includes it in response`() = runTest { + fun `handleEnrolResponse with externalCredential includes it in response`() = runTest { val mockGuid = "mockGuid" val expectedCredentialId = "credentialId" val expectedType = ExternalCredentialType.NHISCard val credential = mockExternalCredential(expectedCredentialId, expectedType) - val project = mockk(relaxed = true) - setupDecryption(project, "decrypted-value".asTokenizableRaw()) + val slot = slot() + every { resultMapper.invoke(capture(slot)) } returns mockk() viewModel.handleEnrolResponse(mockRequest(), mockEnrolResponseWithCredential(mockGuid, credential)) - verify { - resultMapper.invoke( - match { - it.externalCredential?.id == expectedCredentialId && - it.externalCredential?.type == expectedType - }, - ) - } - } - - @Test - fun `handleEnrolResponse with externalCredential but encrypted decryption returns null credential`() = runTest { - val mockGuid = "mockGuid" - val expectedCredentialId = "credentialId" - val expectedType = ExternalCredentialType.NHISCard - val credential = mockExternalCredential(expectedCredentialId, expectedType) - val project = mockk(relaxed = true) - setupDecryption(project, mockk()) - - viewModel.handleEnrolResponse(mockRequest(), mockEnrolResponseWithCredential(mockGuid, credential)) - - verify { - resultMapper.invoke( - match { - it.externalCredential == null - }, - ) - } + val captured = slot.captured as ActionResponse.EnrolActionResponse + assertThat(captured.externalCredential?.id).isEqualTo(expectedCredentialId) + assertThat(captured.externalCredential?.type).isEqualTo(expectedType) } private fun mockEnrolResponseWithCredential( mockGuid: String, - credential: ExternalCredential?, + credential: AppExternalCredential?, ): AppEnrolResponse = mockk { every { guid } returns mockGuid every { externalCredential } returns credential @@ -327,26 +298,12 @@ internal class ClientApiViewModelTest { private fun mockExternalCredential( mockId: String, mockType: ExternalCredentialType, - ): ExternalCredential = mockk { + ): AppExternalCredential = mockk { every { id } returns mockId every { value } returns mockk() every { type } returns mockType } - private fun setupDecryption( - project: Project, - returnValue: TokenizableString, - ) { - coEvery { configRepository.getProject() } returns project - every { - tokenizationProcessor.decrypt( - encrypted = any(), - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) - } returns returnValue - } - private fun mockRequest(): ActionRequest = mockk { every { projectId } returns "projectId" every { actionIdentifier } returns ActionRequestIdentifier("action", "package", "", 1, 0L) diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt index 94bcd23648..ff285868b6 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt @@ -12,6 +12,7 @@ import com.simprints.feature.clientapi.mappers.request.requestFactories.EnrolAct import com.simprints.feature.clientapi.mappers.request.requestFactories.EnrolLastBiometricsActionFactory import com.simprints.feature.clientapi.mappers.request.requestFactories.IdentifyRequestActionFactory import com.simprints.feature.clientapi.mappers.request.requestFactories.VerifyActionFactory +import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL_DOCUMENT_FIELDS import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.infra.orchestration.data.responses.AppMatchResult @@ -139,7 +140,7 @@ class LibSimprintsResponseMapperTest { fun `correctly maps confirm response`() { val expectedValue = "expectedValue".asTokenizableRaw() val expectedType = ExternalCredentialType.NHISCard - val expectedJson = """{"type":"$expectedType","value":"$expectedValue"}""" + val expectedJson = """{"type":"$expectedType","value":"$expectedValue","$SCANNED_CREDENTIAL_DOCUMENT_FIELDS":{}}""" val extras = mapper( ActionResponse.ConfirmActionResponse( actionIdentifier = ConfirmIdentityActionFactory.getIdentifier(), @@ -148,6 +149,7 @@ class LibSimprintsResponseMapperTest { externalCredential = mockk { every { value } returns expectedValue every { type } returns expectedType + every { nonCredentialFields } returns emptyMap() }, ), ) @@ -406,7 +408,7 @@ class LibSimprintsResponseMapperTest { fun `correctly maps enrol response with external credential`() { val expectedValue = "expectedValue".asTokenizableRaw() val expectedType = ExternalCredentialType.NHISCard - val expectedJson = """{"type":"$expectedType","value":"$expectedValue"}""" + val expectedJson = """{"type":"$expectedType","value":"$expectedValue","$SCANNED_CREDENTIAL_DOCUMENT_FIELDS":{}}""" val extras = mapper( ActionResponse.EnrolActionResponse( @@ -417,6 +419,7 @@ class LibSimprintsResponseMapperTest { externalCredential = mockk { every { value } returns expectedValue every { type } returns expectedType + every { nonCredentialFields } returns emptyMap() }, ), ) @@ -538,14 +541,65 @@ class LibSimprintsResponseMapperTest { ) } + @Test + fun `correctly maps external credential with non-credential fields`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val field1Key = "field1Key" + val field1Value = "field1Value" + val field2Key = "field2Key" + val field2Value = "field2Value" + val expectedJson = """{"type":"$expectedType","value":"$expectedValue","$SCANNED_CREDENTIAL_DOCUMENT_FIELDS":{"$field1Key":"$field1Value","$field2Key":"$field2Value"}}""" + + val extras = mapper( + ActionResponse.EnrolActionResponse( + actionIdentifier = EnrolActionFactory.getIdentifier(), + sessionId = "sessionId", + enrolledGuid = "guid", + subjectActions = "subjects", + externalCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + every { nonCredentialFields } returns mapOf(field1Key to field1Value, field2Key to field2Value) + }, + ), + ) + + assertThat(extras.getString(Constants.SIMPRINTS_SCANNED_CREDENTIAL)).isEqualTo(expectedJson) + } + + @Test + fun `correctly maps external credential with empty non-credential fields`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val expectedJson = """{"type":"$expectedType","value":"$expectedValue","$SCANNED_CREDENTIAL_DOCUMENT_FIELDS":{}}""" + + val extras = mapper( + ActionResponse.EnrolActionResponse( + actionIdentifier = EnrolActionFactory.getIdentifier(), + sessionId = "sessionId", + enrolledGuid = "guid", + subjectActions = "subjects", + externalCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + every { nonCredentialFields } returns emptyMap() + }, + ), + ) + + assertThat(extras.getString(Constants.SIMPRINTS_SCANNED_CREDENTIAL)).isEqualTo(expectedJson) + } + @Test fun `when MFID is enabled, identify response contains scanned credential`() { val expectedValue = "expectedValue".asTokenizableRaw() val expectedType = ExternalCredentialType.NHISCard - val expectedJson = """{"type":"$expectedType","value":"$expectedValue"}""" + val expectedJson = """{"type":"$expectedType","value":"$expectedValue","$SCANNED_CREDENTIAL_DOCUMENT_FIELDS":{}}""" val scannedCredential = mockk { every { value } returns expectedValue every { type } returns expectedType + every { nonCredentialFields } returns emptyMap() } val extras = mapper( diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt index b4c2fca5c1..c8012ad5ed 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricContract.kt @@ -2,7 +2,7 @@ package com.simprints.feature.enrollast import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult @ExcludedFromGeneratedTestCoverageReports("Data class") object EnrolLastBiometricContract { @@ -13,12 +13,12 @@ object EnrolLastBiometricContract { userId: TokenizableString, moduleId: TokenizableString, steps: List, - scannedCredential: ScannedCredential?, + credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) = EnrolLastBiometricParams( projectId = projectId, userId = userId, moduleId = moduleId, steps = steps, - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ) } diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt index a9b9bf277b..edd7be85b9 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricParams.kt @@ -6,7 +6,7 @@ import com.simprints.core.domain.capture.BiometricReferenceCapture import com.simprints.core.domain.comparison.ComparisonResult import com.simprints.core.domain.step.StepParams import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.infra.config.store.models.ModalitySdkType import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -19,7 +19,7 @@ data class EnrolLastBiometricParams( val userId: TokenizableString, val moduleId: TokenizableString, val steps: List, - val scannedCredential: ScannedCredential?, + val credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) : StepParams @Serializable diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt index 104a108e40..348770547c 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/EnrolLastBiometricResult.kt @@ -1,8 +1,8 @@ package com.simprints.feature.enrollast import androidx.annotation.Keep -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.step.StepResult +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,5 +11,5 @@ import kotlinx.serialization.Serializable @SerialName("EnrolLastBiometricResult") data class EnrolLastBiometricResult( val newSubjectId: String?, - val externalCredential: ExternalCredential?, + val credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) : StepResult diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt index c9b7f96eed..5f14f2418b 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricFragment.kt @@ -8,7 +8,6 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetDialog import com.simprints.core.domain.common.Modality -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.alert.AlertContract @@ -25,6 +24,7 @@ import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.DUPLICATE import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.GENERAL_ERROR import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.NO_MATCH_RESULTS import com.simprints.feature.enrollast.screen.model.CredentialDialogItem +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.view.ScannedCredentialDialog import com.simprints.infra.events.event.domain.models.AlertScreenEvent import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION @@ -55,7 +55,7 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las viewLifecycleOwner, R.id.enrolLastBiometricFragment, AlertContract.DESTINATION, - ) { finish(newSubjectId = null, credential = null) } + ) { finish(newSubjectId = null, credentialSearchResult = null) } initObservers() viewModel.onViewCreated(params) @@ -72,7 +72,7 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las private fun displayCredentialDialog(credentialDialogItem: CredentialDialogItem) { dialog = ScannedCredentialDialog( context = requireActivity(), - credential = credentialDialogItem.scannedCredential, + credential = credentialDialogItem.scannedCredentialResult, displayedCredential = credentialDialogItem.displayedCredential, onConfirm = { viewModel.enrolBiometric(params, isAddingCredential = true) }, onSkip = { viewModel.enrolBiometric(params, isAddingCredential = false) }, @@ -83,7 +83,7 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las is EnrolLastState.Failed -> showError(result.errorType, result.modalities) is EnrolLastState.Success -> { Toast.makeText(requireContext(), getString(IDR.string.enrol_last_biometrics_success), Toast.LENGTH_LONG).show() - finish(result.newGuid, result.externalCredential) + finish(newSubjectId = result.newGuid, credentialSearchResult = result.credentialSearchResult) } } @@ -128,8 +128,8 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las private fun finish( newSubjectId: String?, - credential: ExternalCredential?, + credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) { - findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId, credential)) + findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId, credentialSearchResult)) } } diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt index 1ec1671071..3cd95eff8a 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModel.kt @@ -14,8 +14,7 @@ import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.GENERAL_E import com.simprints.feature.enrollast.screen.model.CredentialDialogItem import com.simprints.feature.enrollast.screen.usecase.BuildRecordUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.TokenKeyType @@ -57,10 +56,14 @@ internal class EnrolLastBiometricViewModel @Inject constructor( fun onViewCreated(params: EnrolLastBiometricParams) { viewModelScope.launch { - params.scannedCredential?.let { scannedCredential -> + params.credentialSearchResult?.let { credentialSearchResult -> val guidToEnrol = getPreviousEnrolmentResult(params.steps)?.subjectId - if (isCredentialLinkedToAnotherSubject(scannedCredential, guidToEnrol = guidToEnrol, projectId = params.projectId)) { - displayAddCredentialDialog(scannedCredential) + val isCredentialLinkedToAnotherSubject = isCredentialLinkedToAnotherSubject( + confirmedCredential = credentialSearchResult.confirmedCredential, + guidToEnrol = guidToEnrol, + ) + if (isCredentialLinkedToAnotherSubject) { + displayAddCredentialDialog(credentialSearchResult) return@launch } } @@ -85,11 +88,11 @@ internal class EnrolLastBiometricViewModel @Inject constructor( val modalities = projectConfig.general.modalities val previousLastEnrolmentResult = getPreviousEnrolmentResult(params.steps) - val scannedCredential = params.scannedCredential?.takeIf { isAddingCredential } + val credentialSearchResult = params.credentialSearchResult?.takeIf { isAddingCredential } if (previousLastEnrolmentResult != null) { _finish.send( previousLastEnrolmentResult.subjectId - ?.let { subjectId -> EnrolLastState.Success(subjectId, scannedCredential?.toExternalCredential(subjectId)) } + ?.let { subjectId -> EnrolLastState.Success(subjectId, credentialSearchResult) } ?: EnrolLastState.Failed(GENERAL_ERROR, modalities), ) return@launch @@ -104,35 +107,40 @@ internal class EnrolLastBiometricViewModel @Inject constructor( val subject = buildSubject(params, isAddingCredential = isAddingCredential) registerEvent(subject) enrolmentRecordRepository.performActions(listOf(EnrolmentRecordAction.Creation(subject)), project) - _finish.send(EnrolLastState.Success(subject.subjectId, scannedCredential?.toExternalCredential(subject.subjectId))) + _finish.send(EnrolLastState.Success(subject.subjectId, credentialSearchResult)) } catch (t: Throwable) { Simber.e("Enrolment failed", t, tag = ENROLMENT) _finish.send(EnrolLastState.Failed(GENERAL_ERROR, modalities)) } } - private suspend fun displayAddCredentialDialog(scannedCredential: ScannedCredential) { - val project = configRepository.getProject() ?: return - val decrypted = tokenizationProcessor.decrypt( - encrypted = scannedCredential.credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as TokenizableString.Raw - _showAddCredentialDialog.send(CredentialDialogItem(scannedCredential, decrypted)) + private fun displayAddCredentialDialog(credentialSearchResult: ExternalCredentialSearchResult.Complete) { + val scannedCredential = credentialSearchResult.scannedCredentialResult + val confirmedCredential = credentialSearchResult.confirmedCredential + _showAddCredentialDialog.send( + CredentialDialogItem( + scannedCredential, + confirmedCredential, + ), + ) } private suspend fun isCredentialLinkedToAnotherSubject( - scannedCredential: ScannedCredential?, + confirmedCredential: TokenizableString.Raw?, guidToEnrol: String?, - projectId: String, ): Boolean { - if (scannedCredential == null || guidToEnrol == null) return false - + if (confirmedCredential == null || guidToEnrol == null) return false + val project = configRepository.getProject() ?: return false + val credential = tokenizationProcessor.encrypt( + decrypted = confirmedCredential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as TokenizableString.Tokenized return enrolmentRecordRepository .load( EnrolmentRecordQuery( - projectId = projectId, - externalCredential = scannedCredential.credential, + projectId = project.id, + externalCredential = credential, ), ).any { it.subjectId != guidToEnrol } } diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt index 80b2541427..59bf58181a 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/EnrolLastState.kt @@ -2,14 +2,14 @@ package com.simprints.feature.enrollast.screen import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.Modality -import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult @ExcludedFromGeneratedTestCoverageReports("Data class") internal sealed class EnrolLastState { @ExcludedFromGeneratedTestCoverageReports("Data class") data class Success( val newGuid: String, - val externalCredential: ExternalCredential?, + val credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) : EnrolLastState() @ExcludedFromGeneratedTestCoverageReports("Data class") diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt index afe4cc1011..7d454dad90 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt @@ -2,10 +2,10 @@ package com.simprints.feature.enrollast.screen.model import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult @ExcludedFromGeneratedTestCoverageReports("Data struct") internal data class CredentialDialogItem( - val scannedCredential: ScannedCredential, + val scannedCredentialResult: ScannedCredentialResult, val displayedCredential: TokenizableString.Raw, ) diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCase.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCase.kt index 551bf863ed..58af1d4395 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCase.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCase.kt @@ -1,13 +1,12 @@ package com.simprints.feature.enrollast.screen.usecase -import com.simprints.core.domain.reference.BiometricReference import com.simprints.core.domain.capture.BiometricReferenceCapture +import com.simprints.core.domain.reference.BiometricReference import com.simprints.core.domain.reference.BiometricTemplate import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.ExternalCredentialMapper import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecord import com.simprints.infra.eventsync.sync.common.EnrolmentRecordFactory import java.util.Date @@ -17,14 +16,15 @@ import javax.inject.Inject internal class BuildRecordUseCase @Inject constructor( private val timeHelper: TimeHelper, private val enrolmentRecordFactory: EnrolmentRecordFactory, + private val credentialMapper: ExternalCredentialMapper, ) { - operator fun invoke( + suspend operator fun invoke( params: EnrolLastBiometricParams, isAddingCredential: Boolean, ): EnrolmentRecord { val subjectId = UUID.randomUUID().toString() - val externalCredentials = if (isAddingCredential) { - getExternalCredentialResult(params.scannedCredential, subjectId)?.let(::listOf) ?: emptyList() + val externalCredentials = if (isAddingCredential && params.credentialSearchResult != null) { + credentialMapper.mapExternalCredential(params.credentialSearchResult, subjectId).let(::listOf) } else { emptyList() } @@ -43,11 +43,6 @@ internal class BuildRecordUseCase @Inject constructor( ) } - private fun getExternalCredentialResult( - credential: ScannedCredential?, - subjectId: String, - ) = credential?.toExternalCredential(subjectId) - private fun BiometricReferenceCapture.toBiometricReference() = BiometricReference( referenceId = referenceId, modality = modality, diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt index a8951fb206..48b4a01859 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/EnrolLastBiometricViewModelTest.kt @@ -3,6 +3,7 @@ package com.simprints.feature.enrollast.screen import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.* import com.jraska.livedata.test +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp @@ -10,12 +11,12 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.usecase.BuildRecordUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecord @@ -42,6 +43,9 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var timeHelper: TimeHelper + @MockK + private lateinit var credentialSearchResult: ExternalCredentialSearchResult.Complete + @MockK lateinit var configRepository: ConfigRepository @@ -67,7 +71,7 @@ internal class EnrolLastBiometricViewModelTest { lateinit var enrolmentRecord: EnrolmentRecord @MockK - lateinit var scannedCredential: ScannedCredential + lateinit var scannedCredentialResult: ScannedCredentialResult @MockK lateinit var tokenizationProcessor: TokenizationProcessor @@ -95,6 +99,8 @@ internal class EnrolLastBiometricViewModelTest { every { enrolmentRecord.subjectId } returns guidToEnrol + every { credentialSearchResult.scannedCredentialResult } returns scannedCredentialResult + viewModel = EnrolLastBiometricViewModel( timeHelper = timeHelper, configRepository = configRepository, @@ -109,6 +115,7 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `only calls enrol once`() = runTest { + coEvery { configRepository.getProject() } returns null viewModel.onViewCreated( createParams( listOf( @@ -142,7 +149,7 @@ internal class EnrolLastBiometricViewModelTest { .test() .value() .getContentIfNotHandled() - assertThat(result).isEqualTo(EnrolLastState.Success(newGuid = "previousSubjectId", externalCredential = null)) + assertThat(result).isEqualTo(EnrolLastState.Success(newGuid = "previousSubjectId", credentialSearchResult = null)) } @Test @@ -293,16 +300,12 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `shows add credential dialog when scanned credential is linked to another subject`() = runTest { - val decryptedCredential = "decryptedCredential".asTokenizableRaw() + val confirmedCredential = "decryptedCredential".asTokenizableRaw() + val encryptedCredential = mockk() + every { credentialSearchResult.confirmedCredential } returns confirmedCredential + coEvery { tokenizationProcessor.encrypt(confirmedCredential, any(), project) } returns encryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns listOf(enrolmentRecord) coEvery { configRepository.getProject() } returns project - coEvery { - tokenizationProcessor.decrypt( - encrypted = scannedCredential.credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) - } returns decryptedCredential viewModel.onViewCreated( createParams( @@ -318,24 +321,16 @@ internal class EnrolLastBiometricViewModelTest { .getContentIfNotHandled() assertThat(result).isNotNull() - assertThat(result?.scannedCredential).isEqualTo(scannedCredential) - assertThat(result?.displayedCredential).isEqualTo(decryptedCredential) + assertThat(result?.scannedCredentialResult).isEqualTo(credentialSearchResult.scannedCredentialResult) + assertThat(result?.displayedCredential).isEqualTo(confirmedCredential) coVerify(exactly = 0) { buildRecord.invoke(any(), any()) } coVerify(exactly = 0) { enrolmentRecordRepository.performActions(any(), any()) } } @Test fun `add credential dialog is not shown when there is no result`() = runTest { - val decryptedCredential = "decryptedCredential".asTokenizableRaw() coEvery { enrolmentRecordRepository.load(any()) } returns listOf(enrolmentRecord) coEvery { configRepository.getProject() } returns project - coEvery { - tokenizationProcessor.decrypt( - encrypted = scannedCredential.credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) - } returns decryptedCredential viewModel.onViewCreated(createParams(steps = listOf())) @@ -344,21 +339,13 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `add credential dialog is not shown when there are no credentials`() = runTest { - val decryptedCredential = "decryptedCredential".asTokenizableRaw() coEvery { enrolmentRecordRepository.load(any()) } returns listOf(enrolmentRecord) coEvery { configRepository.getProject() } returns project - coEvery { - tokenizationProcessor.decrypt( - encrypted = scannedCredential.credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) - } returns decryptedCredential viewModel.onViewCreated( createParams( steps = listOf(EnrolLastBiometricStepResult.EnrolLastBiometricsResult(subjectId = enrolmentRecord.subjectId)), - credentials = null, + credentialResult = null, ), ) @@ -367,16 +354,9 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `add credential dialog is not shown when credential is already linked to same subject`() = runTest { - val decryptedCredential = "decryptedCredential".asTokenizableRaw() + coEvery { tokenizationProcessor.encrypt(any(), any(), project) } returns mockk() coEvery { enrolmentRecordRepository.load(any()) } returns listOf(enrolmentRecord) coEvery { configRepository.getProject() } returns project - coEvery { - tokenizationProcessor.decrypt( - encrypted = scannedCredential.credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) - } returns decryptedCredential viewModel.onViewCreated( createParams( @@ -391,13 +371,13 @@ internal class EnrolLastBiometricViewModelTest { private fun createParams( steps: List, - credentials: ScannedCredential? = scannedCredential, + credentialResult: ExternalCredentialSearchResult.Complete? = credentialSearchResult, ) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, - scannedCredential = credentials, + credentialSearchResult = credentialResult, ) companion object { diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCaseTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCaseTest.kt index 88deb5b8c0..4edfdcb566 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCaseTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildRecordUseCaseTest.kt @@ -1,22 +1,24 @@ package com.simprints.feature.enrollast.screen.usecase import com.google.common.truth.Truth.* -import com.simprints.core.domain.common.Modality -import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.capture.BiometricReferenceCapture import com.simprints.core.domain.capture.BiometricTemplateCapture +import com.simprints.core.domain.common.Modality import com.simprints.core.domain.common.TemplateIdentifier +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialMapper +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.infra.eventsync.sync.common.EnrolmentRecordFactory import com.simprints.testtools.unit.EncodingUtilsImplForTests import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -25,7 +27,10 @@ class BuildRecordUseCaseTest { private lateinit var timeHelper: TimeHelper @MockK - private lateinit var scannedCredential: ScannedCredential + private lateinit var credentialSearchResult: ExternalCredentialSearchResult.Complete + + @MockK + private lateinit var credentialMapper: ExternalCredentialMapper private lateinit var useCase: BuildRecordUseCase @@ -39,18 +44,26 @@ class BuildRecordUseCaseTest { encodingUtils = EncodingUtilsImplForTests, timeHelper = timeHelper, ) - useCase = BuildRecordUseCase(timeHelper = timeHelper, enrolmentRecordFactory = enrolmentRecordFactory) + useCase = BuildRecordUseCase( + timeHelper = timeHelper, + enrolmentRecordFactory = enrolmentRecordFactory, + credentialMapper = credentialMapper, + ) } @Test - fun `has no references if no steps provided`() { - val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = false) + fun `has no references if no steps provided`() = runTest { + val result = + useCase( + createParams(steps = emptyList(), credentialSearchResult = credentialSearchResult), + isAddingCredential = false, + ) assertThat(result.references).isEmpty() } @Test - fun `has no references if no valid steps provided`() { + fun `has no references if no valid steps provided`() = runTest { val result = useCase( createParams( steps = listOf( @@ -58,7 +71,7 @@ class BuildRecordUseCaseTest { EnrolLastBiometricStepResult.MatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.MatchResult(emptyList(), mockk()), ), - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ), isAddingCredential = false, ) @@ -67,7 +80,7 @@ class BuildRecordUseCaseTest { } @Test - fun `maps first available fingerprint capture step results`() { + fun `maps first available fingerprint capture step results`() = runTest { val result = useCase( params = createParams( steps = listOf( @@ -89,7 +102,7 @@ class BuildRecordUseCaseTest { ), ), ), - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ), isAddingCredential = false, ) @@ -105,7 +118,7 @@ class BuildRecordUseCaseTest { } @Test - fun `maps all provided fingerprint capture samples`() { + fun `maps all provided fingerprint capture samples`() = runTest { val result = useCase( createParams( steps = listOf( @@ -129,7 +142,7 @@ class BuildRecordUseCaseTest { ), ), ), - scannedCredential, + credentialSearchResult = credentialSearchResult, ), isAddingCredential = false, ) @@ -143,7 +156,7 @@ class BuildRecordUseCaseTest { } @Test - fun `maps first available face capture step results`() { + fun `maps first available face capture step results`() = runTest { val result = useCase( params = createParams( listOf( @@ -165,7 +178,7 @@ class BuildRecordUseCaseTest { ), ), ), - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ), isAddingCredential = false, ) @@ -175,22 +188,21 @@ class BuildRecordUseCaseTest { } @Test - fun `includes external credential when isAddingCredential is true and scannedCredential is not null`() { + fun `includes external credential when isAddingCredential is true and scannedCredential is not null`() = runTest { val mockTokenized = mockk() val mockCredentialType = mockk() + val mockExternalCredential = mockk { + every { value } returns mockTokenized + every { type } returns mockCredentialType + } - val scannedCredential = ScannedCredential( - credential = mockTokenized, - credentialType = mockCredentialType, - documentImagePath = null, - zoomedCredentialImagePath = null, - credentialBoundingBox = null, - scanStartTime = Timestamp(1L), - scanEndTime = Timestamp(1L), - scannedValue = TokenizableString.Raw("test"), - ) + coEvery { credentialMapper.mapExternalCredential(credentialSearchResult, any()) } returns mockExternalCredential - val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = true) + val result = + useCase( + createParams(steps = emptyList(), credentialSearchResult = credentialSearchResult), + isAddingCredential = true, + ) assertThat(result.externalCredentials).hasSize(1) assertThat(result.externalCredentials.first().value).isEqualTo(mockTokenized) @@ -198,21 +210,33 @@ class BuildRecordUseCaseTest { } @Test - fun `has no external credentials when isAddingCredential is true but scannedCredential is null`() { - val result = useCase(createParams(steps = emptyList(), scannedCredential = null), isAddingCredential = true) + fun `has no external credentials when isAddingCredential is true but scannedCredential is null`() = runTest { + val result = + useCase(createParams(steps = emptyList(), credentialSearchResult = null), isAddingCredential = true) + + assertThat(result.externalCredentials).isEmpty() + } + + @Test + fun `has no external credentials when isAddingCredential is false even if credentialSearchResult is not null`() = runTest { + val result = + useCase( + createParams(steps = emptyList(), credentialSearchResult = credentialSearchResult), + isAddingCredential = false, + ) assertThat(result.externalCredentials).isEmpty() } private fun createParams( steps: List, - scannedCredential: ScannedCredential?, + credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ) private fun mockFingerprintResults(finger: TemplateIdentifier) = BiometricTemplateCapture( diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialMapper.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialMapper.kt new file mode 100644 index 0000000000..0746bc16fd --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialMapper.kt @@ -0,0 +1,32 @@ +package com.simprints.feature.externalcredential + +import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import javax.inject.Inject + +class ExternalCredentialMapper @Inject constructor( + private val tokenizationProcessor: TokenizationProcessor, + private val configRepository: ConfigRepository, +) { + suspend fun mapExternalCredential( + searchResult: ExternalCredentialSearchResult.Complete, + subjectId: String, + ): ExternalCredential { + val scannedCredentialResult = searchResult.scannedCredentialResult + val confirmedCredential = searchResult.confirmedCredential + val encrypted = tokenizationProcessor.encrypt( + decrypted = confirmedCredential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = configRepository.getProject()!!, + ) as TokenizableString.Tokenized + return ExternalCredential( + id = scannedCredentialResult.credentialScanId, + value = encrypted, + subjectId = subjectId, + type = scannedCredentialResult.credentialType, + ) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt index 189e5e7009..8bf0aff961 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt @@ -4,27 +4,45 @@ import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.step.StepResult +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.model.CredentialMatch -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * Results of the external credential 1:L match (where L is '1' or really close to 1). - * - * @param flowType flow type. Either [FlowType.ENROL] or [FlowType.IDENTIFY] - * @param scannedCredential information about the credential that was scanned - * @param matchResults if [scannedCredential] exists in local database, this field contains match results between the biometric probe taken - * during the flow, and probes linked to the [scannedCredential] - */ @Keep @Serializable @SerialName("ExternalCredentialSearchResult") @ExcludedFromGeneratedTestCoverageReports("Data class") -data class ExternalCredentialSearchResult( - val flowType: FlowType, - val scannedCredential: ScannedCredential?, - val matchResults: List, -) : StepResult { - val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful) +sealed class ExternalCredentialSearchResult : StepResult { + abstract val flowType: FlowType + + @Serializable + @SerialName("ExternalCredentialSearchResult.Skipped") + data class Skipped( + override val flowType: FlowType, + val skipReason: ExternalCredentialSelectionEvent.SkipReason, + ) : ExternalCredentialSearchResult() + + /** + * Results of the external credential 1:L match (where L is '1' or really close to 1). + * + * @param flowType flow type. Either [FlowType.ENROL] or [FlowType.IDENTIFY] + * @param scannedCredentialResult information about the credential that was scanned + * @param confirmedCredential credential value that was confirmed by the user. Might be different from the credential value in the + * [scannedCredentialResult] + * @param matchResults if [scannedCredentialResult] exists in local database, this field contains match results between the biometric probe taken + * during the flow, and probes linked to the [scannedCredentialResult] + */ + @Serializable + @SerialName("ExternalCredentialSearchResult.Complete") + data class Complete( + override val flowType: FlowType, + val scannedCredentialResult: ScannedCredentialResult, + val confirmedCredential: TokenizableString.Raw, + val matchResults: List, + ) : ExternalCredentialSearchResult() { + val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful) + } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt index ed89e87670..462409bb94 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt @@ -18,6 +18,10 @@ data class BoundingBox( val bottom: Int, ) : JavaSerializable -fun Rect?.toBoundingBox(): BoundingBox = if (this == null) BoundingBox(0, 0, 0, 0) else BoundingBox(left, top, right, bottom) +fun Rect?.toBoundingBox(): BoundingBox = if (this == null) { + BoundingBox(0, 0, 0, 0) +} else { + BoundingBox(left, top, right, bottom) +} fun BoundingBox.toRect(): Rect = Rect(left, top, right, bottom) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index 7fbe7ed1a8..d1673452d3 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -45,7 +45,8 @@ internal class ExternalCredentialViewModel @Inject internal constructor( private lateinit var selectionStartTime: Timestamp private lateinit var selectionEventId: String private lateinit var captureStartTime: Timestamp - private var selectedSkipReason: ExternalCredentialSelectionEvent.SkipReason? = null + var selectedSkipReason: ExternalCredentialSelectionEvent.SkipReason? = null + private set private var selectedSkipOtherText: String? = null init { @@ -97,17 +98,22 @@ internal class ExternalCredentialViewModel @Inject internal constructor( fun finish(result: ExternalCredentialSearchResult) { viewModelScope.launch { - if (result.scannedCredential == null) { - selectedSkipReason?.let { reason -> - eventsTracker.saveSkippedEvent(selectionStartTime, reason, selectedSkipOtherText) + when (result) { + is ExternalCredentialSearchResult.Complete -> { + eventsTracker.saveCaptureEvents( + credentialSearchResult = result, + subjectId = params.subjectId.orEmpty(), + startTime = captureStartTime, + selectionEventId = selectionEventId, + ) + } + is ExternalCredentialSearchResult.Skipped -> { + eventsTracker.saveSkippedEvent( + startTime = selectionStartTime, + skipReason = result.skipReason, + skipOther = selectedSkipOtherText, + ) } - } else { - eventsTracker.saveCaptureEvents( - captureStartTime, - params.subjectId.orEmpty(), - result.scannedCredential, - selectionEventId, - ) } _finishEvent.send(result) } 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 d3f05b75fd..a3ad55519d 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 @@ -38,7 +38,7 @@ import com.simprints.feature.externalcredential.screens.scanocr.model.LightingCo 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.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.camera.qrscan.CameraFocusManager @@ -406,7 +406,7 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex * The animation state is stored in the [isAnimatingCompletion]. If it is set to true, the navigation action is set to * [pendingFinishAction] which will be executed once animations are complete. If false, the navigation will proceed immediately. */ - private fun scheduleFinish(credential: ScannedCredential) { + private fun scheduleFinish(credential: ScannedCredentialResult) { val navigationAction = { findNavController().navigateSafely( this@ExternalCredentialScanOcrFragment, 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 0b39cdd8d1..7dd587cf55 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 @@ -7,34 +7,25 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherBG -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock 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.asExternalCredentialType +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.GetCredentialCoordinatesUseCase +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.KeepOnlyBestDetectedBlockUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.NormalizeBitmapToPreviewUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentConfigUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOntoCredentialUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.scanocr.usecase.ScanMfidDocumentUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.experimental -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.credential.store.CredentialImageRepository -import com.simprints.infra.credential.store.model.CredentialScanImageType.ZoomedInCredential -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.resources.R import dagger.assisted.Assisted @@ -52,13 +43,10 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( private val timeHelper: TimeHelper, private val normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase, private val cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase, - private val getCredentialCoordinatesUseCase: GetCredentialCoordinatesUseCase, + private val scanMfidDocumentUseCase: ScanMfidDocumentUseCase, + private val buildScannedCredentialResultUseCase: BuildScannedCredentialResultUseCase, private val getLightingConditionsAssessmentConfig: GetLightingConditionsAssessmentConfigUseCase, private val getLightingConditionsAssessment: GetLightingConditionsAssessmentUseCase, - private val keepOnlyBestDetectedBlockUseCase: KeepOnlyBestDetectedBlockUseCase, - private val credentialImageRepository: CredentialImageRepository, - private val zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase, - private val tokenizationProcessor: TokenizationProcessor, private val configRepository: ConfigRepository, @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -67,11 +55,11 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( fun create(ocrDocumentType: OcrDocumentType): ExternalCredentialScanOcrViewModel } - private var detectedBlocks: List = emptyList() + private var scannedMfidDocuments: List = emptyList() val isProcessingImage = AtomicBoolean(false) val isOcrActive: Boolean - get() = detectedBlocks.isNotEmpty() + get() = scannedMfidDocuments.isNotEmpty() private var ocrState: ScanOcrState = ScanOcrState.EMPTY set(value) { field = value @@ -79,9 +67,9 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } private val _scanOcrStateLiveData = MutableLiveData(ocrState) val scanOcrStateLiveData: LiveData = _scanOcrStateLiveData - val finishOcrEvent: LiveData> + val finishOcrEvent: LiveData> get() = _finishOcrEvent - private val _finishOcrEvent = MutableLiveData>() + private val _finishOcrEvent = MutableLiveData>() private val lightingConditionsAssessmentFlow = MutableStateFlow(null) val lightingConditionsAssessment: LiveData = @@ -155,15 +143,19 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } if (!isOcrAllowed) return@launch - val detectedBlock = getCredentialCoordinatesUseCase(bitmap = cropped, documentType = ocrDocumentType) ?: return@launch + val mfidConfig = configRepository.getProjectConfiguration().multifactorId ?: return@launch + val scannedMfidDocument = + scanMfidDocumentUseCase(bitmap = cropped, documentType = ocrDocumentType, config = mfidConfig) ?: return@launch Simber.d("Detected OCR") - detectedBlocks += detectedBlock - updateState { - ScanOcrState.ScanningInProgress( - ocrDocumentType = ocrDocumentType, - successfulCaptures = detectedBlocks.size, - scansRequired = ocrConfig.capturesRequired, - ) + if (isScanningInProgress) { + scannedMfidDocuments += scannedMfidDocument + updateState { + ScanOcrState.ScanningInProgress( + ocrDocumentType = ocrDocumentType, + successfulCaptures = scannedMfidDocuments.size, + scansRequired = ocrConfig.capturesRequired, + ) + } } } finally { isProcessingImage.set(false) @@ -174,49 +166,12 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( fun processOcrResultsAndFinish() { updateState { ScanOcrState.Complete } viewModelScope.launch { - // Missing project at this point is impossible, so no special handling required - val project = configRepository.getProject() ?: return@launch - - val detectedBlock = keepOnlyBestDetectedBlockUseCase(detectedBlocks, ocrDocumentType) - val credentialType = detectedBlock.documentType.asExternalCredentialType() - val blockBoundingBox = detectedBlock.blockBoundingBox - val zoomedCredentialImagePath = buildZoomedImagePath(detectedBlock) - val detectedValueRaw = detectedBlock.readoutValue.asTokenizableRaw() - val credential = tokenizationProcessor.encrypt( - decrypted = detectedValueRaw, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as TokenizableString.Tokenized - - val scannedCredential = ScannedCredential( - credential = credential, - credentialType = credentialType, - documentImagePath = detectedBlock.imagePath, - zoomedCredentialImagePath = zoomedCredentialImagePath, - credentialBoundingBox = blockBoundingBox, - scanStartTime = startTime, - scanEndTime = timeHelper.now(), - scannedValue = detectedValueRaw, - ) - _finishOcrEvent.send(scannedCredential) - detectedBlocks = emptyList() + val scannedCredentialResult = buildScannedCredentialResultUseCase(scannedMfidDocuments, ocrDocumentType, startTime) + _finishOcrEvent.send(scannedCredentialResult) + scannedMfidDocuments = emptyList() } } - private suspend fun buildZoomedImagePath(detectedBlock: DetectedOcrBlock): String? = try { - credentialImageRepository.saveCredentialScan( - bitmap = zoomOntoCredentialUseCase(detectedBlock.imagePath, detectedBlock.blockBoundingBox), - imageType = ZoomedInCredential, - ) - } catch (e: Exception) { - Simber.e( - "Unable to zoom into bounding box [${detectedBlock.blockBoundingBox}] of ${detectedBlock.documentType} image ${detectedBlock.imagePath}", - e, - MULTI_FACTOR_ID, - ) - null - } - fun imageProcessingStarted() { isProcessingImage.set(true) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt deleted file mode 100644 index 8e9f5845ef..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.model - -import com.google.mlkit.vision.text.Text -import com.simprints.feature.externalcredential.model.BoundingBox - -/** - * Result of the OCR credential detection of image. [Text.TextBlock] contains a [Text.Line] that was contains the detected credential. - * [readoutValue] is a normalized string that was read from the [Text.Line] (no extra space, trimmed). - * - * To save memory, the image is not stored directly in the data class. Rather this data class keeps a file path reference to the image - * in [imagePath]. - * - * @param imagePath path to bitmap that was used for OCR - * @param documentType type of a supported document - * @param blockBoundingBox bounding box of block in which [lineBoundingBox] was detected - * @param lineBoundingBox bounding box of line that contained [Text.Element] objects that were concatenated and normalized to produce a [readoutValue] - * @param readoutValue normalized readout value from all [Text.Element] objects in [lineBoundingBox] - */ -internal data class DetectedOcrBlock( - val imagePath: String, - val documentType: OcrDocumentType, - val blockBoundingBox: BoundingBox, - val lineBoundingBox: BoundingBox, - val readoutValue: String, -) 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 new file mode 100644 index 0000000000..b19f986a2c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrScanResult.kt @@ -0,0 +1,39 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Keep +@Serializable +@ExcludedFromGeneratedTestCoverageReports("Data class") +internal sealed class OcrScanResult { + abstract val credential: OcrLine + + @Serializable + @SerialName("OcrScanResult.GhanaNhisCard") + data class GhanaNhisCard( + override val credential: OcrLine, + val name: OcrLine? = null, + val dateOfBirth: OcrLine? = null, + val sex: OcrLine? = null, + val dateOfIssue: OcrLine? = null, + ) : OcrScanResult() + + @Serializable + @SerialName("OcrScanResult.GhanaIdCard") + data class GhanaIdCard( + override val credential: OcrLine, + val surname: OcrLine? = null, + val firstName: OcrLine? = null, + val nationality: OcrLine? = null, + val dateOfBirth: OcrLine? = null, + val height: OcrLine? = null, + val documentNumber: OcrLine? = null, + val placeOfIssue: OcrLine? = null, + val dateOfIssue: OcrLine? = null, + val dateOfExpiry: OcrLine? = null, + ) : OcrScanResult() +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/ScannedMfidDocument.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/ScannedMfidDocument.kt new file mode 100644 index 0000000000..cb37f85a2b --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/ScannedMfidDocument.kt @@ -0,0 +1,29 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Result of the OCR scan for the supported credential document. Its subclasses define the currently-supported document types with their + * respective non-credential fields. + * + * @param imagePath path to full-res bitmap that was used for the OCR + * @param ocrScanResult detected blocks for the external credential + */ + +@Keep +@Serializable +@SerialName("ScannedMfidDocument") +@ExcludedFromGeneratedTestCoverageReports("Data class") +internal data class ScannedMfidDocument( + val imagePath: String, + val ocrScanResult: OcrScanResult, +) { + val type: OcrDocumentType + get() = when (ocrScanResult) { + is OcrScanResult.GhanaIdCard -> OcrDocumentType.GhanaIdCard + is OcrScanResult.GhanaNhisCard -> OcrDocumentType.NhisCard + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt index 0ef455af6e..2841aff65c 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/reader/OcrModel.kt @@ -2,16 +2,19 @@ package com.simprints.feature.externalcredential.screens.scanocr.reader import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.feature.externalcredential.model.BoundingBox +import kotlinx.serialization.Serializable +import java.io.Serializable as JavaSerializable /** * Wrapper for all scanned text after the OCR * * @param allLines all lines from blocks sorted by bounding box top coordinate ascending */ +@Serializable @ExcludedFromGeneratedTestCoverageReports("Data class") internal data class OcrText( val allLines: List, -) +) : JavaSerializable /** * A single line of text detected by the OCR kit. @@ -22,6 +25,7 @@ internal data class OcrText( * @param blockBoundingBox parent coordinates * @param confidence overall confidence of the text value based on the average confidence for each character (aka element) in [text] */ +@Serializable @ExcludedFromGeneratedTestCoverageReports("Data class") internal data class OcrLine( val id: Int, @@ -29,4 +33,4 @@ internal data class OcrLine( val boundingBox: BoundingBox, val blockBoundingBox: BoundingBox, val confidence: Float, -) +) : JavaSerializable 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 new file mode 100644 index 0000000000..b9e3db160c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCase.kt @@ -0,0 +1,83 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.core.domain.tokenization.asTokenizableRaw +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.reader.OcrLine +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import javax.inject.Inject + +internal class BuildMfidDocumentUseCase @Inject constructor( + private val getBestReadoutBasedOnConfidenceUseCase: GetBestReadoutBasedOnConfidenceUseCase, +) { + operator fun invoke( + scannedDocuments: List, + documentType: OcrDocumentType, + ): MfidDocument = when (documentType) { + OcrDocumentType.NhisCard -> buildNhisCard(scannedDocuments) + OcrDocumentType.GhanaIdCard -> buildGhanaIdCard(scannedDocuments) + } + + /** + * Builds a single NHIS card readout object from multiple OCR scan attempts. + * Each field is is reconstructed by selecting the most likely readout from all available scan results using OCR confidence aggregation. + * + * @param scannedDocuments OCR scan attempts for the same NHIS card + * @return merged NHIS document with the most likely field values + */ + private fun buildNhisCard(scannedDocuments: List): MfidDocument.GhanaNhisCard { + val credential = scannedDocuments + .map { it.ocrScanResult.credential.text } + .let { credentials -> getBestReadoutBasedOnConfidenceUseCase(credentials, targetLength = CREDENTIAL_LENGTH_GHANA_NHIS_CARD) } + .asTokenizableRaw() + val results = scannedDocuments.map { it.ocrScanResult }.filterIsInstance() + return MfidDocument.GhanaNhisCard( + credential = credential, + name = results.bestReadoutOrNull { it.name }, + dateOfBirth = results.bestReadoutOrNull { it.dateOfBirth }, + sex = results.bestReadoutOrNull { it.sex }, + dateOfIssue = results.bestReadoutOrNull { it.dateOfIssue }, + ) + } + + /** + * Builds a single Ghana ID card readout object from multiple OCR scan attempts. + * Each field is is reconstructed by selecting the most likely readout from all available scan results using OCR confidence aggregation. + * + * @param scannedDocuments OCR scan attempts for the same Ghana ID card + * @return merged Ghana ID document with the most likely field values + */ + private fun buildGhanaIdCard(scannedDocuments: List): MfidDocument.GhanaIdCard { + val credential = scannedDocuments + .map { it.ocrScanResult.credential.text } + .let { credentials -> getBestReadoutBasedOnConfidenceUseCase(credentials, targetLength = CREDENTIAL_LENGTH_GHANA_ID) } + .asTokenizableRaw() + val results = scannedDocuments.map { it.ocrScanResult }.filterIsInstance() + return MfidDocument.GhanaIdCard( + credential = credential, + surname = results.bestReadoutOrNull { it.surname }, + firstName = results.bestReadoutOrNull { it.firstName }, + nationality = results.bestReadoutOrNull { it.nationality }, + dateOfBirth = results.bestReadoutOrNull { it.dateOfBirth }, + height = results.bestReadoutOrNull { it.height }, + documentNumber = results.bestReadoutOrNull { it.documentNumber }, + placeOfIssue = results.bestReadoutOrNull { it.placeOfIssue }, + dateOfIssue = results.bestReadoutOrNull { it.dateOfIssue }, + dateOfExpiry = results.bestReadoutOrNull { it.dateOfExpiry }, + ) + } + + private fun List.bestReadoutOrNull(ocrLine: (T) -> OcrLine?) = mapNotNull(ocrLine) + .takeUnless(List::isEmpty) + ?.let { getBestReadoutBasedOnConfidenceUseCase(it.map(OcrLine::text)) } + ?.asTokenizableRaw() + + companion object { + // NHIS membership number contains 8 digits: 12345678 + private const val CREDENTIAL_LENGTH_GHANA_NHIS_CARD = 8 + + // Ghana ID field contains 15 chars: GHA-123456789-0 + private const val CREDENTIAL_LENGTH_GHANA_ID = 15 + } +} 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 new file mode 100644 index 0000000000..98a1def045 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCase.kt @@ -0,0 +1,37 @@ +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.ScannedMfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import javax.inject.Inject + +internal class BuildScannedCredentialResultUseCase @Inject constructor( + private val buildMfidDocumentUseCase: BuildMfidDocumentUseCase, + private val createAndSaveZoomedImageUseCase: CreateAndSaveZoomedImageUseCase, + private val timeHelper: TimeHelper, +) { + suspend operator fun invoke( + scannedDocuments: List, + documentType: OcrDocumentType, + startTime: Timestamp, + ): ScannedCredentialResult { + val document = buildMfidDocumentUseCase(scannedDocuments, documentType) + + // Saving the image and its OCR data closest to what the user have seen on the camera preview. This allows for natural look when + // 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) + + return ScannedCredentialResult( + document = document, + documentImagePath = latestImagePath, + zoomedCredentialImagePath = zoomedCredentialImagePath, + credentialBoundingBox = latestCredentialOcrBlock.blockBoundingBox, + scanStartTime = startTime, + scanEndTime = timeHelper.now(), + ) + } +} 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 new file mode 100644 index 0000000000..d68e12b501 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CreateAndSaveZoomedImageUseCase.kt @@ -0,0 +1,30 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.infra.credential.store.CredentialImageRepository +import com.simprints.infra.credential.store.model.CredentialScanImageType.ZoomedInCredential +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import javax.inject.Inject + +internal class CreateAndSaveZoomedImageUseCase @Inject constructor( + private val zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase, + private val credentialImageRepository: CredentialImageRepository, +) { + suspend operator fun invoke( + ocrLine: OcrLine, + fullSizeImagePath: String, + ) = try { + credentialImageRepository.saveCredentialScan( + bitmap = zoomOntoCredentialUseCase(fullSizeImagePath, ocrLine.blockBoundingBox), + imageType = ZoomedInCredential, + ) + } catch (e: Exception) { + Simber.e( + "Unable to zoom into bounding box [${ocrLine.blockBoundingBox}] image $fullSizeImagePath", + e, + MULTI_FACTOR_ID, + ) + null + } +} 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 index 5b0f2d4979..4174b5c2e8 100644 --- 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 @@ -2,6 +2,8 @@ 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() { @@ -17,12 +19,14 @@ internal class CropDocumentFromPreviewUseCase @Inject constructor() { val width = right - left val height = bottom - top - if (width <= 0 || height <= 0) { - throw IllegalStateException( - "Invalid OCR crop dimensions: width=$width, height=$height. CutoutRect=[$cutoutRect], bitmapSize(w,h)=[${bitmap.width}x${bitmap.height}]", - ) + 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) } - - return Bitmap.createBitmap(bitmap, left, top, width, height) } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt deleted file mode 100644 index ade0e55533..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock -import javax.inject.Inject - -internal class FindBestTextBlockForCredentialUseCase @Inject constructor( - private val calculateLevenshteinDistanceUseCase: CalculateLevenshteinDistanceUseCase, -) { - /** - * Finds the detected OCR block that contains the readout value most similar to the given credential string. - * - * This method first attempts to find an exact match by comparing the readout value of each block with the credential. If no exact - * match is found, it uses Levenshtein distance to find the block with the smallest edit distance to the credential. - * - * @param credential the target credential string to match - * @param detectedBlocks list of detected OCR blocks to search through. Must not be empty - * @return the detected OCR block with the best matching readout value - * @throws IllegalArgumentException if no blocks provided - */ - operator fun invoke( - credential: String, - detectedBlocks: List, - ): DetectedOcrBlock { - if (detectedBlocks.isEmpty()) { - throw IllegalArgumentException("OCR: cannot find match for credential, provided detected block list is empty") - } - - // Searching from the end of detected blocks to maximize chances of getting the image closest to what the user have seen on the - // camera preview. This allows for natural look when transitioning to the next screen, as the best fitting text block will be as - // close to the last frame the user sees as possible. - for (block in detectedBlocks.asReversed()) { - if (block.readoutValue == credential) { - return block - } - } - - // If no exact match, finding the closest one using Levenshtein distance. Updating its credential value to the given for consistency - return detectedBlocks - .minBy { block -> - calculateLevenshteinDistanceUseCase(credential, block.readoutValue) - }.copy(readoutValue = credential) - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBestReadoutBasedOnConfidenceUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBestReadoutBasedOnConfidenceUseCase.kt new file mode 100644 index 0000000000..721fe499fd --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBestReadoutBasedOnConfidenceUseCase.kt @@ -0,0 +1,52 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.infra.logging.Simber +import javax.inject.Inject + +internal class GetBestReadoutBasedOnConfidenceUseCase @Inject constructor() { + /** + * Constructs the most likely string by selecting the most frequent character at each position across all detected OCR + * blocks. This is necessary because during the OCR readouts, detection mechanisms might confuse characters (think, 'l' versus 'I'). + * + * To account for that, this method puts the most frequent character at each position across all readings. + * + * Example: ["ABC", "ACD", "CCD"], targetLength=3 -> "ACD" + * - Position 0: 'A' appears 2 times, 'C' appears 1 time -> 'A' wins + * - Position 1: 'B' appears 1 time, 'C' appears 2 times -> 'C' wins + * - Position 2: 'C' appears 1 time, 'D' appears 2 times -> 'D' wins + * Result: 'ACD' + * + * @param readoutValues list of OCR detection results containing readout values + * @param targetLength expected length of the field. When provided, only readouts matching this length participate in voting. + * When null, the most common length among readouts is used as the target. + * @return most likely string based on character frequency voting + * @throws [IllegalArgumentException] if [readoutValues] is empty or no readouts match the target length + */ + operator fun invoke( + readoutValues: List, + targetLength: Int? = null, + ): String { + // Either target length or a maximum out of all readouts + val length = targetLength ?: readoutValues + .groupingBy { it.length } + .eachCount() + .maxBy { it.value } + .key + + val detectedValues = readoutValues.filter { it.length == length } + if (detectedValues.isEmpty()) { + Simber.d("OCR: no values of length [$length] is detected in readout values $readoutValues") + throw IllegalArgumentException("OCR block list is empty, cannot extract external credential from it") + } + + return (0 until length) + .map { position -> + detectedValues + .map { it[position] } + .groupingBy { it } + .eachCount() + .maxBy { it.value } + .key + }.joinToString(separator = "") + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt deleted file mode 100644 index 6a34597b5a..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID -import com.simprints.infra.logging.Simber -import javax.inject.Inject - -internal class GetExternalCredentialBasedOnConfidenceUseCase @Inject constructor() { - /** - * Constructs the most likely credential string by selecting the most frequent character at each position across all detected OCR - * blocks. This is necessary because during the OCR readouts, detection mechanisms might confuse characters (think, 'l' versus 'I'). - * - * To account for that, this method puts the most frequent character at each position across all readings. - * - * Example: ["ABC", "ACD", "CCD"], credentialLength=3 -> "ACD" - * - Position 0: 'A' appears 2 times, 'C' appears 1 time -> 'A' wins - * - Position 1: 'B' appears 1 time, 'C' appears 2 times -> 'C' wins - * - Position 2: 'C' appears 1 time, 'D' appears 2 times -> 'D' wins - * Result: 'ACD' - * - * Example: ["ABC", "AC"], credentialLength=3 -> "ACD" - * - * @param detectedBlocks list of OCR detection results containing readout values - * @param credentialLength target length of the external credential - * @return most likely credential string based on character frequency voting - * @throws [IllegalArgumentException] if [detectedBlocks] is empty - */ - operator fun invoke( - detectedBlocks: List, - credentialLength: Int, - ): String { - val detectedValues: List = detectedBlocks - .map(DetectedOcrBlock::readoutValue) - .filter { it.length == credentialLength } - if (detectedValues.isEmpty()) { - throw IllegalArgumentException("OCR block list is empty, cannot extract external credential from it") - } - - // Grouping characters at each position across all strings and picking the most frequent one - return (0 until credentialLength) - .map { position -> - detectedValues - .map { it[position] } - .groupingBy { it } - .eachCount() - .maxBy { it.value } - .key - }.joinToString(separator = "") - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrReaderUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrReaderUseCase.kt new file mode 100644 index 0000000000..ab44714b98 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrReaderUseCase.kt @@ -0,0 +1,40 @@ +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.OcrReader +import javax.inject.Inject + +internal class GhanaIdCardOcrReaderUseCase @Inject constructor() { + operator fun invoke( + ocrReader: OcrReader, + isCapturingAllFields: Boolean, + ): OcrScanResult.GhanaIdCard? { + val credential = ocrReader.find { matchesPattern(GHANA_ID_PATTERN) } ?: return null + + return if (isCapturingAllFields) { + // Intentionally using short unique substrings instead of full field names. + // OCR output is often noisy, and shorter distinctive fragments (e.g. "uance" from "Date of Issuance") reduce matching failures + OcrScanResult.GhanaIdCard( + credential = credential, + surname = ocrReader.findBelow("surname"), + firstName = ocrReader.findBelow("first"), + nationality = ocrReader.findBelow("natio"), + dateOfBirth = ocrReader.findBelow("birth"), + height = ocrReader.findBelow("height"), + documentNumber = ocrReader.findBelow("docu"), + placeOfIssue = ocrReader.findBelow("place"), + dateOfIssue = ocrReader.findBelow("mission"), + dateOfExpiry = ocrReader.findBelow("expiry"), + ) + } else { + OcrScanResult.GhanaIdCard(credential) + } + } + + private fun OcrReader.findBelow(text: String) = find { isBelow { containsText(text) } } + + companion object { + // Ghana ID card number pattern is "GHA-123456789-0" + val GHANA_ID_PATTERN = Regex("^GHA-\\d{9}-\\d$") + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt deleted file mode 100644 index 3d336c3423..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine -import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader -import javax.inject.Inject - -internal class GhanaIdCardOcrSelectorUseCase @Inject constructor() { - operator fun invoke(ocrReader: OcrReader): OcrLine? = ocrReader.find { matchesPattern(GHANA_ID_PATTERN) } - - companion object { - // Ghana ID card number pattern is "GHA-123456789-0" - private val GHANA_ID_PATTERN = Regex("^GHA-\\d{9}-\\d$") - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrReaderUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrReaderUseCase.kt new file mode 100644 index 0000000000..e0714c91b2 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrReaderUseCase.kt @@ -0,0 +1,35 @@ +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.OcrReader +import javax.inject.Inject + +internal class GhanaNhisCardOcrReaderUseCase @Inject constructor() { + operator fun invoke( + ocrReader: OcrReader, + isCapturingAllFields: Boolean, + ): OcrScanResult.GhanaNhisCard? { + val credential = ocrReader.find { matchesPattern(NHIS_PATTERN) } ?: return null + + return if (isCapturingAllFields) { + // Intentionally using short unique substrings instead of full field names. + // OCR output is often noisy, and shorter distinctive fragments (e.g. "of issue" from "Date of Issue") reduce matching failures + OcrScanResult.GhanaNhisCard( + credential = credential, + name = ocrReader.findBelow("name"), + dateOfBirth = ocrReader.findBelow("birth"), + sex = ocrReader.findBelow("sex"), + dateOfIssue = ocrReader.findBelow("of issue"), + ) + } else { + OcrScanResult.GhanaNhisCard(credential) + } + } + + private fun OcrReader.findBelow(text: String) = find { isBelow { containsText(text) } } + + companion object { + // NHIS Card membership is 8 digits long + val NHIS_PATTERN = Regex("^\\d{8}$") + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt deleted file mode 100644 index 95c6c3ca47..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine -import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrReader -import javax.inject.Inject - -internal class GhanaNhisCardOcrSelectorUseCase @Inject constructor() { - operator fun invoke(ocrReader: OcrReader): OcrLine? = ocrReader.find { matchesPattern(NHIS_PATTERN) } - - companion object { - // NHIS Card membership is 8 digits long - private val NHIS_PATTERN = Regex("^\\d{8}$") - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt deleted file mode 100644 index 3f477015a6..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType -import com.simprints.infra.credential.store.CredentialImageRepository -import javax.inject.Inject - -internal class KeepOnlyBestDetectedBlockUseCase @Inject constructor( - private val getExternalCredentialBasedOnConfidenceUseCase: GetExternalCredentialBasedOnConfidenceUseCase, - private val findBestTextBlockForCredentialUseCase: FindBestTextBlockForCredentialUseCase, - private val credentialImageRepository: CredentialImageRepository, -) { - suspend operator fun invoke( - allDetectedBlock: List, - documentType: OcrDocumentType, - ): DetectedOcrBlock { - val credentialLength = when (documentType) { - OcrDocumentType.NhisCard -> 8 // NHIS membership number contains 8 digits: 12345678 - OcrDocumentType.GhanaIdCard -> 15 // Ghana ID field contains 15 chars: GHA-123456789-0 - } - val externalCredential = getExternalCredentialBasedOnConfidenceUseCase(allDetectedBlock, credentialLength) - val detectedBlock = findBestTextBlockForCredentialUseCase(credential = externalCredential, detectedBlocks = allDetectedBlock) - - // Deleting cached scan images for all remaining blocks - allDetectedBlock - .map(DetectedOcrBlock::imagePath) - .filterNot { imagePath -> imagePath == detectedBlock.imagePath } - .onEach { imagePath -> credentialImageRepository.deleteByPath(imagePath) } - return detectedBlock - } -} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt similarity index 57% rename from feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt rename to feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt index 5fa0df2651..2757c6fbde 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCase.kt @@ -1,9 +1,10 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import android.graphics.Bitmap -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock 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.reader.OcrReader +import com.simprints.infra.config.store.models.MultiFactorIdConfiguration import com.simprints.infra.credential.store.CredentialImageRepository import com.simprints.infra.credential.store.model.CredentialScanImageType.FullDocument import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID @@ -12,32 +13,31 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class GetCredentialCoordinatesUseCase @Inject constructor( +internal class ScanMfidDocumentUseCase @Inject constructor( private val readTextFromImage: ReadTextFromImageUseCase, - private val ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase, - private val ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase, + private val ghanaNhisCardOcrReaderUseCase: GhanaNhisCardOcrReaderUseCase, + private val ghanaIdCardOcrReaderUseCase: GhanaIdCardOcrReaderUseCase, private val credentialImageRepository: CredentialImageRepository, ) { suspend operator fun invoke( bitmap: Bitmap, documentType: OcrDocumentType, - ): DetectedOcrBlock? { + config: MultiFactorIdConfiguration, + ): ScannedMfidDocument? { + val isCapturingAllFields = when (documentType) { + OcrDocumentType.NhisCard -> config.nhisCardConfig?.isCapturingAllFields + OcrDocumentType.GhanaIdCard -> config.ghanaIdCardConfig?.isCapturingAllFields + } ?: false return try { val ocrText = readTextFromImage(bitmap) ?: return null val ocrReader = OcrReader(ocrText) - val credentialOcrLine = when (documentType) { - OcrDocumentType.NhisCard -> ghanaNhisCardOcrSelectorUseCase(ocrReader) - OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrSelectorUseCase(ocrReader) + val scanResult = when (documentType) { + OcrDocumentType.NhisCard -> ghanaNhisCardOcrReaderUseCase(ocrReader, isCapturingAllFields) + OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrReaderUseCase(ocrReader, isCapturingAllFields) } - if (credentialOcrLine != null) { + if (scanResult != null) { val savedImagePath = credentialImageRepository.saveCredentialScan(bitmap, imageType = FullDocument) - DetectedOcrBlock( - imagePath = savedImagePath, - documentType = documentType, - blockBoundingBox = credentialOcrLine.blockBoundingBox, - lineBoundingBox = credentialOcrLine.boundingBox, - readoutValue = credentialOcrLine.text, - ) + ScannedMfidDocument(savedImagePath, scanResult) } else { null } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt index 53715ebb6d..f9d0a155df 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -22,7 +22,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.extentions.getCurrentPermissionStatus import com.simprints.core.tools.extentions.hasPermission @@ -30,7 +29,8 @@ import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanQrBinding import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.camera.qrscan.CameraHelper @@ -137,23 +137,21 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext } private fun renderScanComplete(state: ScanQrState.QrCodeCaptured) = with(binding) { - val qrCodeRaw = state.qrCode + val qrCodeValue = state.qrCode qrInstructionsText.isVisible = false qrPreviewCard.isVisible = true - qrPreviewText.text = qrCodeRaw.value + qrPreviewText.text = state.qrCode.value buttonScan.setText(IDR.string.mfid_continue) buttonScan.isEnabled = true buttonScan.setOnClickListener { - if (viewModel.isValidQrCodeFormat(qrCodeRaw)) { - val args = ScannedCredential( - credential = state.qrCodeEncrypted, - credentialType = ExternalCredentialType.QRCode, + if (viewModel.isValidQrCodeFormat(qrCodeValue)) { + val args = ScannedCredentialResult( + document = MfidDocument.GhanaQrCode(qrCodeValue), documentImagePath = null, credentialBoundingBox = null, zoomedCredentialImagePath = null, scanStartTime = state.scanStartTime, scanEndTime = state.scanEndTime, - scannedValue = state.qrCode, ) findNavController().navigateSafely( this@ExternalCredentialScanQrFragment, @@ -161,7 +159,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext ) } else { showInvalidQrCodeFormatDialog( - qrCodeValue = qrCodeRaw, + qrCodeValue = qrCodeValue, onDismiss = { dismissDialog() viewModel.updateCapturedValue(null) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt index 01842080d2..827446e4a5 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt @@ -10,9 +10,6 @@ import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanqr.usecase.ExternalCredentialQrCodeValidatorUseCase -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -21,8 +18,6 @@ import javax.inject.Inject internal class ExternalCredentialScanQrViewModel @Inject constructor( private val timeHelper: TimeHelper, private val externalCredentialQrCodeValidator: ExternalCredentialQrCodeValidatorUseCase, - private val configRepository: ConfigRepository, - private val tokenizationProcessor: TokenizationProcessor, ) : ViewModel() { private lateinit var startTime: Timestamp private var state: ScanQrState = ScanQrState.ReadyToScan @@ -42,19 +37,11 @@ internal class ExternalCredentialScanQrViewModel @Inject constructor( val newState = when (value) { null -> ScanQrState.ReadyToScan - else -> configRepository.getProject()?.let { project -> - val qrCodeEncrypted = tokenizationProcessor.encrypt( - decrypted = value.asTokenizableRaw(), - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as TokenizableString.Tokenized - ScanQrState.QrCodeCaptured( - scanStartTime = startTime, - scanEndTime = timeHelper.now(), - qrCode = value.asTokenizableRaw(), - qrCodeEncrypted = qrCodeEncrypted, - ) - } ?: ScanQrState.ReadyToScan + else -> ScanQrState.QrCodeCaptured( + scanStartTime = startTime, + scanEndTime = timeHelper.now(), + qrCode = value.asTokenizableRaw(), + ) } updateState { newState } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt index c3f2720756..ef7bfb2375 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt @@ -14,6 +14,5 @@ sealed class ScanQrState { val scanStartTime: Timestamp, val scanEndTime: Timestamp, val qrCode: TokenizableString.Raw, - val qrCodeEncrypted: TokenizableString.Tokenized, ) : ScanQrState() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 2bc007c62c..49887bacf7 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt @@ -27,7 +27,7 @@ import com.simprints.feature.externalcredential.ext.getCredentialFieldTitle import com.simprints.feature.externalcredential.ext.getCredentialTypeString import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOntoCredentialUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID @@ -49,7 +49,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") return viewModelFactory.create( - scannedCredential = args.scannedCredential, + scannedCredentialResult = args.scannedCredentialResult, externalCredentialParams = mainViewModel.params, ) as T } @@ -80,7 +80,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext private fun initObservers() { viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> renderCredentialCard(state) - renderSearchProgress(state.searchState, state.scannedCredential.credentialType, state.flowType) + renderSearchProgress(state.searchState, state.scannedCredentialResult.credentialType, state.flowType) renderButtons(state) } viewModel.finishEvent.observe( @@ -92,12 +92,12 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext } private fun renderCredentialCard(state: SearchCredentialState) = with(binding) { - val credential = state.displayedCredential?.value.orEmpty() - val credentialType = state.scannedCredential.credentialType + val credential = state.displayedCredential.value + val credentialType = state.scannedCredentialResult.credentialType val credentialField = resources.getCredentialFieldTitle(credentialType) val currentEditTextValue = credentialEditText.text.toString() val isEditingCredential = state.isEditingCredential - renderImage(state.scannedCredential) + renderImage(state.scannedCredentialResult) renderCredentialEdit(state) credential.takeIf { currentEditTextValue.isEmpty() }?.let { credentialEditText.setText(it) // Setting only once at the start @@ -223,7 +223,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext } } - private fun renderImage(credential: ScannedCredential) { + private fun renderImage(credential: ScannedCredentialResult) { val documentImagePath: String? = credential.documentImagePath binding.documentPreview.isVisible = documentImagePath != null if (documentImagePath == null) return diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt index e3bb78b393..57b16f3c17 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt @@ -13,9 +13,9 @@ import com.simprints.core.livedata.send import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaIdCardOcrSelectorUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaIdCardOcrReaderUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrReaderUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState import com.simprints.feature.externalcredential.screens.search.usecase.MatchCandidatesUseCase @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch import com.simprints.infra.resources.R as IDR internal class ExternalCredentialSearchViewModel @AssistedInject constructor( - @Assisted val scannedCredential: ScannedCredential, + @Assisted val scannedCredentialResult: ScannedCredentialResult, @Assisted val externalCredentialParams: ExternalCredentialParams, private val timeHelper: TimeHelper, private val configRepository: ConfigRepository, @@ -42,13 +42,11 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( private val tokenizationProcessor: TokenizationProcessor, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val eventsTracker: ExternalCredentialEventTrackerUseCase, - private val ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase, - private val ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase, ) : ViewModel() { @AssistedFactory interface Factory { fun create( - scannedCredential: ScannedCredential, + scannedCredentialResult: ScannedCredentialResult, externalCredentialParams: ExternalCredentialParams, ): ExternalCredentialSearchViewModel } @@ -57,7 +55,7 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( get() = _finishEvent private val _finishEvent = MutableLiveData>() private var state: SearchCredentialState = - SearchCredentialState.buildInitial(scannedCredential, externalCredentialParams.flowType) + SearchCredentialState.buildInitial(scannedCredentialResult, externalCredentialParams.flowType) set(value) { field = value _stateLiveData.postValue(value) @@ -74,8 +72,7 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( init { viewModelScope.launch { configRepository.getProject()?.let { - decryptCredentialToDisplay(it, scannedCredential.credential) - searchSubjectsLinkedToCredential(it, scannedCredential.credential) + searchSubjectsLinkedToCredential(it, scannedCredentialResult.credential) } } } @@ -91,21 +88,13 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( fun confirmCredentialUpdate(updatedCredential: TokenizableString.Raw) { viewModelScope.launch { configRepository.getProject()?.let { project -> - val encryptedCredential = tokenizationProcessor.encrypt( - decrypted = updatedCredential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as TokenizableString.Tokenized updateState { currentState -> currentState.copy( isConfirmed = false, - scannedCredential = currentState.scannedCredential.copy( - credential = encryptedCredential, - ), displayedCredential = updatedCredential, ) } - searchSubjectsLinkedToCredential(project, encryptedCredential) + searchSubjectsLinkedToCredential(project, updatedCredential) } } } @@ -137,26 +126,21 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( } } - private fun decryptCredentialToDisplay( + private suspend fun searchSubjectsLinkedToCredential( project: Project, - credential: TokenizableString.Tokenized, + credential: TokenizableString.Raw, ) { - val decrypted = tokenizationProcessor.decrypt( - encrypted = credential, + val encryptedCredential = tokenizationProcessor.encrypt( + decrypted = credential, tokenKeyType = TokenKeyType.ExternalCredential, project = project, - ) as TokenizableString.Raw - updateState { it.copy(displayedCredential = decrypted) } - } - - private suspend fun searchSubjectsLinkedToCredential( - project: Project, - credential: TokenizableString.Tokenized, - ) { + ) as TokenizableString.Tokenized updateState { it.copy(searchState = SearchState.Searching) } val searchStartTime = timeHelper.now() - val candidates = enrolmentRecordRepository.load(EnrolmentRecordQuery(projectId = project.id, externalCredential = credential)) - eventsTracker.saveSearchEvent(searchStartTime, scannedCredential.credentialScanId, candidates) + val candidates = enrolmentRecordRepository.load( + EnrolmentRecordQuery(projectId = project.id, externalCredential = encryptedCredential), + ) + eventsTracker.saveSearchEvent(searchStartTime, scannedCredentialResult.credentialScanId, candidates) when { candidates.isEmpty() -> { @@ -165,7 +149,7 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( else -> { val projectConfig = configRepository.getProjectConfiguration() - val matches = matchCandidatesUseCase(candidates, credential, externalCredentialParams, project, projectConfig) + val matches = matchCandidatesUseCase(candidates, encryptedCredential, externalCredentialParams, project, projectConfig) updateState { state -> state.copy(searchState = SearchState.CredentialLinked(matchResults = matches)) } } @@ -176,7 +160,7 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( * Function for QOL improvement. Sets the keyboard to specific [InputType] based on the credential type. QR code and Ghana ID card are * alpha-numeric, while the NHIS card memberships contain only digits. */ - fun getKeyBoardInputType() = when (scannedCredential.credentialType) { + fun getKeyBoardInputType() = when (scannedCredentialResult.credentialType) { // NHIS card membership contains only numbers ExternalCredentialType.NHISCard -> InputType.TYPE_CLASS_NUMBER ExternalCredentialType.GhanaIdCard -> InputType.TYPE_CLASS_TEXT @@ -206,9 +190,10 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( is SearchState.CredentialLinked -> searchState.matchResults } _finishEvent.send( - ExternalCredentialSearchResult( + ExternalCredentialSearchResult.Complete( flowType = externalCredentialParams.flowType, - scannedCredential = state.scannedCredential, + scannedCredentialResult = state.scannedCredentialResult, + confirmedCredential = state.displayedCredential, matchResults = matches, ), ) @@ -217,14 +202,14 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( fun isCredentialFormatValid(credential: String?): Boolean { if (credential == null) return false - return when (scannedCredential.credentialType) { + return when (scannedCredentialResult.credentialType) { ExternalCredentialType.NHISCard -> { // 8 digits - ghanaNhisCardValidationUseCase(credential) + GhanaNhisCardOcrReaderUseCase.NHIS_PATTERN.matches(credential) } ExternalCredentialType.GhanaIdCard -> { // Ghana ID card number pattern is "GHA-123456789-0" - ghanaIdValidationUseCase(credential) + GhanaIdCardOcrReaderUseCase.GHANA_ID_PATTERN.matches(credential) } ExternalCredentialType.QRCode -> { // No QR code validation as of 2025.4.1 diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/MfidDocument.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/MfidDocument.kt new file mode 100644 index 0000000000..563fc46fb7 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/MfidDocument.kt @@ -0,0 +1,46 @@ +package com.simprints.feature.externalcredential.screens.search.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.tokenization.TokenizableString +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.io.Serializable as JavaSerializable + +@Keep +@Serializable +@ExcludedFromGeneratedTestCoverageReports("Data class") +sealed class MfidDocument : JavaSerializable { + abstract val credential: TokenizableString.Raw + + @Serializable + @SerialName("MfidDocument.GhanaNhisCard") + data class GhanaNhisCard( + override val credential: TokenizableString.Raw, + val name: TokenizableString.Raw? = null, + val dateOfBirth: TokenizableString.Raw? = null, + val sex: TokenizableString.Raw? = null, + val dateOfIssue: TokenizableString.Raw? = null, + ) : MfidDocument() + + @Serializable + @SerialName("MfidDocument.GhanaIdCard") + data class GhanaIdCard( + override val credential: TokenizableString.Raw, + val surname: TokenizableString.Raw? = null, + val firstName: TokenizableString.Raw? = null, + val nationality: TokenizableString.Raw? = null, + val dateOfBirth: TokenizableString.Raw? = null, + val height: TokenizableString.Raw? = null, + val documentNumber: TokenizableString.Raw? = null, + val placeOfIssue: TokenizableString.Raw? = null, + val dateOfIssue: TokenizableString.Raw? = null, + val dateOfExpiry: TokenizableString.Raw? = null, + ) : MfidDocument() + + @Serializable + @SerialName("MfidDocument.GhanaQrCode") + data class GhanaQrCode( + override val credential: TokenizableString.Raw, + ) : MfidDocument() +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialResult.kt similarity index 54% rename from feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt rename to feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialResult.kt index db7d67799f..5edf70dcc6 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialResult.kt @@ -1,10 +1,9 @@ package com.simprints.feature.externalcredential.screens.search.model import androidx.annotation.Keep -import com.simprints.core.domain.externalcredential.ExternalCredential +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.step.StepResult -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.time.Timestamp import com.simprints.core.tools.utils.randomUUID import com.simprints.feature.externalcredential.model.BoundingBox @@ -13,22 +12,23 @@ import kotlinx.serialization.Serializable @Keep @Serializable -@SerialName("ScannedCredential") -data class ScannedCredential( +@SerialName("ScannedCredentialResult") +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class ScannedCredentialResult( val credentialScanId: String = randomUUID(), - val credential: TokenizableString.Tokenized, - val credentialType: ExternalCredentialType, + val document: MfidDocument, val documentImagePath: String?, val zoomedCredentialImagePath: String?, val credentialBoundingBox: BoundingBox?, val scanStartTime: Timestamp, val scanEndTime: Timestamp, - val scannedValue: TokenizableString.Raw, -) : StepResult - -fun ScannedCredential.toExternalCredential(subjectId: String) = ExternalCredential( - id = credentialScanId, - value = credential, - subjectId = subjectId, - type = credentialType, -) +) : StepResult { + val credential + get() = document.credential + val credentialType + get() = when (document) { + is MfidDocument.GhanaIdCard -> ExternalCredentialType.GhanaIdCard + is MfidDocument.GhanaNhisCard -> ExternalCredentialType.NHISCard + is MfidDocument.GhanaQrCode -> ExternalCredentialType.QRCode + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt index fee2e741da..0e20510a76 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt @@ -10,8 +10,8 @@ import kotlin.Boolean @Keep @ExcludedFromGeneratedTestCoverageReports("Data struct") internal data class SearchCredentialState( - val scannedCredential: ScannedCredential, - val displayedCredential: TokenizableString.Raw?, + val scannedCredentialResult: ScannedCredentialResult, + val displayedCredential: TokenizableString.Raw, val flowType: FlowType, val searchState: SearchState, val isConfirmed: Boolean, @@ -19,11 +19,11 @@ internal data class SearchCredentialState( ) { companion object { fun buildInitial( - scannedCredential: ScannedCredential, + scannedCredentialResult: ScannedCredentialResult, flowType: FlowType, ) = SearchCredentialState( - scannedCredential = scannedCredential, - displayedCredential = null, + scannedCredentialResult = scannedCredentialResult, + displayedCredential = scannedCredentialResult.credential, flowType = flowType, searchState = SearchState.Searching, isConfirmed = false, diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt index 76fd72a763..b1bf2bc583 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt @@ -87,10 +87,9 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede // [MS-1166] We should log skip reasons in analytics. buttonSkip.setOnClickListener { mainViewModel.finish( - ExternalCredentialSearchResult( + ExternalCredentialSearchResult.Skipped( flowType = mainViewModel.params.flowType, - scannedCredential = null, - matchResults = emptyList(), + skipReason = viewIdToOption(skipCredentialScanRadioGroup.checkedRadioButtonId), ), ) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt index 888bc334e0..7749f950d3 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt @@ -1,18 +1,15 @@ package com.simprints.feature.externalcredential.usecase import com.simprints.core.domain.common.Modality -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.ExternalCredentialMapper +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.screens.scanocr.usecase.CalculateLevenshteinDistanceUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ModalitySdkType -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecord import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent @@ -31,9 +28,9 @@ import com.simprints.infra.config.store.models.FingerprintConfiguration.FingerCo internal class ExternalCredentialEventTrackerUseCase @Inject constructor( private val timeHelper: TimeHelper, private val configRepository: ConfigRepository, - private val tokenizationProcessor: TokenizationProcessor, private val eventRepository: SessionEventRepository, private val calculateDistance: CalculateLevenshteinDistanceUseCase, + private val externalCredentialMapper: ExternalCredentialMapper, ) { suspend fun saveMatchEvent( startTime: Timestamp, @@ -68,18 +65,21 @@ internal class ExternalCredentialEventTrackerUseCase @Inject constructor( } suspend fun saveCaptureEvents( - startTime: Timestamp, + credentialSearchResult: ExternalCredentialSearchResult.Complete, subjectId: String, - scannedCredential: ScannedCredential, + startTime: Timestamp, selectionEventId: String, ) { - Simber.d("Saving External Credential Events for $scannedCredential") - val credential = scannedCredential.toExternalCredential(subjectId) + Simber.d("Saving External Credential Events for $credentialSearchResult") + val confirmedCredential = credentialSearchResult.confirmedCredential + val scannedCredentialResult = credentialSearchResult.scannedCredentialResult + val type = scannedCredentialResult.credentialType + val externalCredential = externalCredentialMapper.mapExternalCredential(credentialSearchResult, subjectId) eventRepository.addOrUpdateEvent( ExternalCredentialCaptureValueEvent( createdAt = startTime, - payloadId = scannedCredential.credentialScanId, - credential = credential, + payloadId = externalCredential.id, + credential = externalCredential, ), ) @@ -87,38 +87,23 @@ internal class ExternalCredentialEventTrackerUseCase @Inject constructor( ExternalCredentialCaptureEvent( startTime = startTime, endTime = timeHelper.now(), - payloadId = scannedCredential.credentialScanId, - autoCaptureStartTime = scannedCredential.scanStartTime, - autoCaptureEndTime = scannedCredential.scanEndTime, - ocrErrorCount = calculateOcrErrorCount(scannedCredential), - capturedTextLength = getActualCapturedCredentialLength(scannedCredential), - credentialTextLength = getExpectedCredentialValueLength(credential), + payloadId = externalCredential.id, + autoCaptureStartTime = scannedCredentialResult.scanStartTime, + autoCaptureEndTime = scannedCredentialResult.scanEndTime, + ocrErrorCount = calculateDistance(scannedCredentialResult.credential.value, confirmedCredential.value), + capturedTextLength = confirmedCredential.value.length, + credentialTextLength = getExpectedCredentialValueLength(type), selectionId = selectionEventId, ), ) } - private fun getActualCapturedCredentialLength(scannedCredential: ScannedCredential): Int = scannedCredential.scannedValue.value.length - - private fun getExpectedCredentialValueLength(credential: ExternalCredential): Int = when (credential.type) { + private fun getExpectedCredentialValueLength(type: ExternalCredentialType): Int = when (type) { ExternalCredentialType.NHISCard -> NHIS_CARD_ID_LENGTH ExternalCredentialType.GhanaIdCard -> GHANA_ID_CARD_ID_LENGTH ExternalCredentialType.QRCode -> QR_CODE_LENGTH } - private suspend fun calculateOcrErrorCount(scannedCredential: ScannedCredential): Int { - val project = configRepository.getProject() ?: return 0 - val actualCredentialRaw = tokenizationProcessor.decrypt( - scannedCredential.credential, - TokenKeyType.ExternalCredential, - project, - ) - return calculateDistance( - scannedCredential.scannedValue.value, - actualCredentialRaw.value, - ) - } - suspend fun saveSelectionEvent( startTime: Timestamp, endTime: Timestamp, diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt index f006a3f96c..7ca039ccf9 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCase.kt @@ -2,8 +2,8 @@ package com.simprints.feature.externalcredential.usecase import com.simprints.core.SessionCoroutineScope import com.simprints.core.tools.extentions.isValidGuid -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.ExternalCredentialMapper +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.EnrolmentRecordAction @@ -12,14 +12,15 @@ import com.simprints.infra.events.session.SessionEventRepository import kotlinx.coroutines.CoroutineScope import javax.inject.Inject -class ResetExternalCredentialsInSessionUseCase @Inject() constructor( +class ResetExternalCredentialsInSessionUseCase @Inject constructor( private val enrolmentRecordRepository: EnrolmentRecordRepository, private val configRepository: ConfigRepository, private val eventRepository: SessionEventRepository, + private val credentialMapper: ExternalCredentialMapper, @param:SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) { suspend operator fun invoke( - scannedCredential: ScannedCredential? = null, + credentialSearchResult: ExternalCredentialSearchResult.Complete? = null, subjectId: String = "", ) { val enrolmentUpdateEvents = eventRepository @@ -39,13 +40,17 @@ class ResetExternalCredentialsInSessionUseCase @Inject() constructor( } val validSubjectId = subjectId.takeIf { it.isValidGuid() } - val credentialsToAdd = if (validSubjectId != null && scannedCredential != null) { + val credentialsToAdd = if (validSubjectId != null && credentialSearchResult != null) { + val externalCredential = credentialMapper.mapExternalCredential( + searchResult = credentialSearchResult, + subjectId = validSubjectId, + ) listOf( EnrolmentRecordAction.Update( subjectId = subjectId, samplesToAdd = emptyList(), referenceIdsToRemove = emptyList(), - externalCredentialsToAdd = listOf(scannedCredential.toExternalCredential(validSubjectId)), + externalCredentialsToAdd = listOf(externalCredential), externalCredentialIdsToRemove = emptyList(), ), ) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt index ad996aef1a..7278492786 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt @@ -17,13 +17,13 @@ import com.simprints.feature.externalcredential.databinding.DialogScannedCredent import com.simprints.feature.externalcredential.databinding.ItemScannedImageBinding import com.simprints.feature.externalcredential.ext.getCredentialFieldTitle import com.simprints.feature.externalcredential.ext.getCredentialTypeString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.resources.R as IDR @ExcludedFromGeneratedTestCoverageReports("UI class") class ScannedCredentialDialog( context: Context, - private val credential: ScannedCredential, + private val credential: ScannedCredentialResult, private val displayedCredential: TokenizableString.Raw, private val onConfirm: () -> Unit, private val onSkip: () -> Unit, diff --git a/feature/external-credential/src/main/res/navigation/graph_external_credential_internal.xml b/feature/external-credential/src/main/res/navigation/graph_external_credential_internal.xml index 47d96644a7..747dff6dc9 100644 --- a/feature/external-credential/src/main/res/navigation/graph_external_credential_internal.xml +++ b/feature/external-credential/src/main/res/navigation/graph_external_credential_internal.xml @@ -45,8 +45,8 @@ app:popUpTo="@+id/graph_external_credential_internal" app:popUpToInclusive="true" /> diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/ExternalCredentialMapperTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/ExternalCredentialMapperTest.kt new file mode 100644 index 0000000000..402207cdb3 --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/ExternalCredentialMapperTest.kt @@ -0,0 +1,113 @@ +package com.simprints.feature.externalcredential + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +internal class ExternalCredentialMapperTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + + @MockK + lateinit var configRepository: ConfigRepository + + @MockK + lateinit var project: Project + + @MockK + lateinit var searchResult: ExternalCredentialSearchResult.Complete + + @MockK + lateinit var scannedCredentialResult: ScannedCredentialResult + + @MockK + lateinit var confirmedCredential: TokenizableString.Raw + + @MockK + lateinit var encryptedCredential: TokenizableString.Tokenized + + private lateinit var mapper: ExternalCredentialMapper + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + + coEvery { configRepository.getProject() } returns project + every { searchResult.scannedCredentialResult } returns scannedCredentialResult + every { searchResult.confirmedCredential } returns confirmedCredential + every { scannedCredentialResult.credentialScanId } returns SCAN_ID + every { scannedCredentialResult.credentialType } returns ExternalCredentialType.NHISCard + every { tokenizationProcessor.encrypt(confirmedCredential, TokenKeyType.ExternalCredential, project) } returns encryptedCredential + + mapper = ExternalCredentialMapper(tokenizationProcessor, configRepository) + } + + @Test + fun `maps credential scan id to external credential id`() = runTest { + val result = mapCredential() + assertThat(result.id).isEqualTo(SCAN_ID) + } + + @Test + fun `maps encrypted confirmed credential to external credential value`() = runTest { + val result = mapCredential() + assertThat(result.value).isEqualTo(encryptedCredential) + } + + @Test + fun `passes subject id through to external credential`() = runTest { + val result = mapCredential(subjectId = SUBJECT_ID) + assertThat(result.subjectId).isEqualTo(SUBJECT_ID) + } + + @Test + fun `maps credential type from scanned result`() = runTest { + every { scannedCredentialResult.credentialType } returns ExternalCredentialType.GhanaIdCard + val result = mapCredential() + assertThat(result.type).isEqualTo(ExternalCredentialType.GhanaIdCard) + } + + @Test + fun `encrypts confirmed credential using external credential key type`() = runTest { + mapCredential() + coVerify { + tokenizationProcessor.encrypt( + decrypted = confirmedCredential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) + } + } + + @Test + fun `fetches project from config repository for encryption`() = runTest { + mapCredential() + coVerify { configRepository.getProject() } + } + + private suspend fun mapCredential(subjectId: String = SUBJECT_ID) = + mapper.mapExternalCredential(searchResult = searchResult, subjectId = subjectId) + + companion object { + private const val SCAN_ID = "SCAN_ID" + private const val SUBJECT_ID = "SUBJECT_ID" + } +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt index 023dfee2a4..d4a39d45ed 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt @@ -5,14 +5,12 @@ import com.google.common.truth.Truth.* import com.jraska.livedata.test import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.model.ExternalCredentialParams -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent @@ -105,9 +103,12 @@ internal class ExternalCredentialViewModelTest { @Test fun `finish sends result to finishEvent`() = runTest { - val mockResult = mockk(relaxed = true) { - every { scannedCredential } returns null + val mockResult = mockk(relaxed = true) { + every { scannedCredentialResult } returns mockk() } + viewModel.init(createParams(subjectId = "subjectId", FlowType.IDENTIFY)) + viewModel.selectionStarted() + viewModel.setSelectedExternalCredentialType(ExternalCredentialType.QRCode) viewModel.finish(mockResult) val observer = viewModel.finishEvent .test() @@ -123,8 +124,8 @@ internal class ExternalCredentialViewModelTest { val subjectId = "subjectId" val flowType = FlowType.IDENTIFY val params = createParams(subjectId, flowType) - val credentialSearchResult = mockk(relaxed = true) { - every { scannedCredential } returns createScannedCredential() + val credentialSearchResult = mockk(relaxed = true) { + every { scannedCredentialResult } returns createScannedCredential() } viewModel.init(params) viewModel.selectionStarted() @@ -141,8 +142,8 @@ internal class ExternalCredentialViewModelTest { @Test fun `finish saves success flow events`() = runTest { - val mockResult = mockk(relaxed = true) { - every { scannedCredential } returns mockk(relaxed = true) + val mockResult = mockk(relaxed = true) { + every { scannedCredentialResult } returns mockk(relaxed = true) } coEvery { eventsTracker.saveSelectionEvent(any(), any(), any()) } returns "selectionId" @@ -157,9 +158,7 @@ internal class ExternalCredentialViewModelTest { @Test fun `finish saves skip event`() = runTest { - val mockResult = mockk(relaxed = true) { - every { scannedCredential } returns null - } + val mockResult = mockk(relaxed = true) viewModel.selectionStarted() viewModel.skipOptionSelected(ExternalCredentialSelectionEvent.SkipReason.OTHER) viewModel.skipOtherReasonChanged("other") @@ -200,20 +199,16 @@ internal class ExternalCredentialViewModelTest { } private fun createScannedCredential( - credential: String = "credential", - credentialType: ExternalCredentialType = ExternalCredentialType.NHISCard, documentImagePath: String? = "documentImagePath", zoomedCredentialImagePath: String? = "zoomedCredentialImagePath", credentialBoundingBox: BoundingBox? = BoundingBox(0, 0, 100, 100), - ) = ScannedCredential( - credential = credential.asTokenizableEncrypted(), - credentialType = credentialType, + ) = ScannedCredentialResult( + document = mockk(), documentImagePath = documentImagePath, zoomedCredentialImagePath = zoomedCredentialImagePath, credentialBoundingBox = credentialBoundingBox, scanStartTime = Timestamp(1L), scanEndTime = Timestamp(2L), - scannedValue = credential.asTokenizableRaw(), ) private fun createParams( 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 85f7116fad..f111985589 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 @@ -4,28 +4,22 @@ import android.graphics.Bitmap import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.* import com.jraska.livedata.test -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.externalcredential.model.BoundingBox -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock 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.GetCredentialCoordinatesUseCase +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.KeepOnlyBestDetectedBlockUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.NormalizeBitmapToPreviewUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetLightingConditionsAssessmentConfigUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOntoCredentialUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.ScanMfidDocumentUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.MFID_LIGHTING_CONDITIONS_ASSESSMENT_ENABLED import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.credential.store.CredentialImageRepository import com.simprints.infra.resources.R import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* @@ -58,22 +52,13 @@ internal class ExternalCredentialScanOcrViewModelTest { private lateinit var cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase @MockK - private lateinit var getCredentialCoordinatesUseCase: GetCredentialCoordinatesUseCase - - @MockK - private lateinit var getLightingConditionsAssessment: GetLightingConditionsAssessmentUseCase - - @MockK - private lateinit var keepOnlyBestDetectedBlockUseCase: KeepOnlyBestDetectedBlockUseCase - - @MockK - private lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase + private lateinit var scanMfidDocumentUseCase: ScanMfidDocumentUseCase @MockK - private lateinit var credentialImageRepository: CredentialImageRepository + private lateinit var buildScannedCredentialResultUseCase: BuildScannedCredentialResultUseCase @MockK - private lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var getLightingConditionsAssessment: GetLightingConditionsAssessmentUseCase @MockK private lateinit var configRepository: ConfigRepository @@ -100,7 +85,7 @@ internal class ExternalCredentialScanOcrViewModelTest { documentType: OcrDocumentType, customConfig: Map = emptyMap(), ): ExternalCredentialScanOcrViewModel { - val projectConfiguration = mockk { + val projectConfiguration = mockk(relaxed = true) { every { custom } returns customConfig } coEvery { configRepository.getProjectConfiguration() } returns projectConfiguration @@ -110,15 +95,12 @@ internal class ExternalCredentialScanOcrViewModelTest { timeHelper = timeHelper, normalizeBitmapToPreviewUseCase = normalizeBitmapToPreviewUseCase, cropDocumentFromPreviewUseCase = cropDocumentFromPreviewUseCase, - getCredentialCoordinatesUseCase = getCredentialCoordinatesUseCase, + scanMfidDocumentUseCase = scanMfidDocumentUseCase, + buildScannedCredentialResultUseCase = buildScannedCredentialResultUseCase, getLightingConditionsAssessmentConfig = GetLightingConditionsAssessmentConfigUseCase(configRepository), getLightingConditionsAssessment = getLightingConditionsAssessment, - keepOnlyBestDetectedBlockUseCase = keepOnlyBestDetectedBlockUseCase, - zoomOntoCredentialUseCase = zoomOntoCredentialUseCase, - credentialImageRepository = credentialImageRepository, - bgDispatcher = testCoroutineRule.testCoroutineDispatcher, - tokenizationProcessor = tokenizationProcessor, configRepository = configRepository, + bgDispatcher = testCoroutineRule.testCoroutineDispatcher, ) } @@ -144,12 +126,12 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage updates detected blocks and state when OCR successful`() = runTest { - val mockDetectedBlock = mockk() + val mockScannedDocument = mockk() val mockNormalizedBitmap = mockk() val mockCroppedBitmap = mockk() coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap - coEvery { getCredentialCoordinatesUseCase(mockCroppedBitmap, documentType) } returns mockDetectedBlock + coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument val observer = viewModel.scanOcrStateLiveData.test() viewModel.imageProcessingStarted() @@ -202,7 +184,7 @@ internal class ExternalCredentialScanOcrViewModelTest { 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) { getCredentialCoordinatesUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } coVerify(exactly = 0) { getLightingConditionsAssessment.invoke(any(), any()) } assertThat(viewModel.isOcrActive).isFalse() } @@ -233,7 +215,7 @@ internal class ExternalCredentialScanOcrViewModelTest { assertThat(observer.value()).isEqualTo(lightingConditionsAssessment) coVerify(exactly = 1) { getLightingConditionsAssessment(mockCroppedBitmap, any()) } - coVerify(exactly = 0) { getCredentialCoordinatesUseCase.invoke(any(), any()) } + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(any(), any(), any()) } } @Test @@ -260,7 +242,7 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processImage skips image processing when config is not yet initialized and scanning is not in progress`() = runTest { val configLoadingDeferred = CompletableDeferred() - val projectConfiguration = mockk { + val projectConfiguration = mockk(relaxed = true) { every { custom } returns mapOf( MFID_LIGHTING_CONDITIONS_ASSESSMENT_ENABLED to JsonPrimitive(true), ) @@ -273,15 +255,12 @@ internal class ExternalCredentialScanOcrViewModelTest { timeHelper = timeHelper, normalizeBitmapToPreviewUseCase = normalizeBitmapToPreviewUseCase, cropDocumentFromPreviewUseCase = cropDocumentFromPreviewUseCase, - getCredentialCoordinatesUseCase = getCredentialCoordinatesUseCase, + scanMfidDocumentUseCase = scanMfidDocumentUseCase, + buildScannedCredentialResultUseCase = buildScannedCredentialResultUseCase, getLightingConditionsAssessmentConfig = GetLightingConditionsAssessmentConfigUseCase(configRepository), getLightingConditionsAssessment = getLightingConditionsAssessment, - keepOnlyBestDetectedBlockUseCase = keepOnlyBestDetectedBlockUseCase, - zoomOntoCredentialUseCase = zoomOntoCredentialUseCase, - credentialImageRepository = credentialImageRepository, - bgDispatcher = testCoroutineRule.testCoroutineDispatcher, - tokenizationProcessor = tokenizationProcessor, configRepository = configRepository, + bgDispatcher = testCoroutineRule.testCoroutineDispatcher, ) viewModel.processImage(bitmap, cropConfig) @@ -299,71 +278,37 @@ internal class ExternalCredentialScanOcrViewModelTest { @Test fun `processOcrResultsAndFinish sends finish event with scanned credential`() = runTest { - val detectedBlockImagePath = "detectedBlockImagePath" - val readoutValue = "readoutValue" - val zoomedImagePath = "zoomedImagePath" - val mockBoundingBox = mockk() - val mockBitmap = mockk() - val mockProject = mockk() - val mockTokenizedCredential = mockk() - - val mockBestBlock = mockk { - every { documentType } returns OcrDocumentType.NhisCard - every { blockBoundingBox } returns mockBoundingBox - every { imagePath } returns detectedBlockImagePath - every { this@mockk.readoutValue } returns readoutValue - } - - coEvery { configRepository.getProject() } returns mockProject - coEvery { keepOnlyBestDetectedBlockUseCase(any(), documentType) } returns mockBestBlock - every { tokenizationProcessor.encrypt(any(), TokenKeyType.ExternalCredential, mockProject) } returns mockTokenizedCredential - coEvery { zoomOntoCredentialUseCase(detectedBlockImagePath, mockBoundingBox) } returns mockBitmap - coEvery { credentialImageRepository.saveCredentialScan(mockBitmap, any()) } returns zoomedImagePath + val mockScannedCredentialResult = mockk() + coEvery { buildScannedCredentialResultUseCase(any(), documentType, any()) } returns mockScannedCredentialResult val finishObserver = viewModel.finishOcrEvent.test() val stateObserver = viewModel.scanOcrStateLiveData.test() - viewModel.startScanning() // Initialises capture timing + viewModel.startScanning() viewModel.processOcrResultsAndFinish() - val scannedCredential = finishObserver.value()?.peekContent() - assertThat(scannedCredential?.credential).isEqualTo(mockTokenizedCredential) - assertThat(scannedCredential?.documentImagePath).isEqualTo(detectedBlockImagePath) - assertThat(scannedCredential?.zoomedCredentialImagePath).isEqualTo(zoomedImagePath) - assertThat(scannedCredential?.credentialBoundingBox).isEqualTo(mockBoundingBox) + assertThat(finishObserver.value()?.peekContent()).isEqualTo(mockScannedCredentialResult) assertThat(stateObserver.value()).isEqualTo(ScanOcrState.Complete) assertThat(viewModel.isOcrActive).isFalse() } @Test - fun `processOcrResultsAndFinish sets null zoomed image path when zoom fails`() = runTest { - val detectedBlockImagePath = "detectedBlockImagePath" - val readoutValue = "readoutValue" - val mockBoundingBox = mockk() - val mockProject = mockk() - val mockTokenizedCredential = mockk() - - val mockBestBlock = mockk { - every { documentType } returns OcrDocumentType.NhisCard - every { blockBoundingBox } returns mockBoundingBox - every { imagePath } returns detectedBlockImagePath - every { this@mockk.readoutValue } returns readoutValue - } - - coEvery { configRepository.getProject() } returns mockProject - coEvery { keepOnlyBestDetectedBlockUseCase(any(), documentType) } returns mockBestBlock - every { tokenizationProcessor.encrypt(any(), TokenKeyType.ExternalCredential, mockProject) } returns mockTokenizedCredential - coEvery { zoomOntoCredentialUseCase(detectedBlockImagePath, mockBoundingBox) } throws Exception("Zoom failed") + fun `processOcrResultsAndFinish passes accumulated documents to build use case`() = runTest { + val mockScannedDocument = mockk() + val mockScannedCredentialResult = mockk() + val mockNormalizedBitmap = mockk() + val mockCroppedBitmap = mockk() - val finishObserver = viewModel.finishOcrEvent.test() + coEvery { normalizeBitmapToPreviewUseCase(bitmap, cropConfig) } returns mockNormalizedBitmap + coEvery { cropDocumentFromPreviewUseCase(mockNormalizedBitmap, any()) } returns mockCroppedBitmap + coEvery { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument + coEvery { buildScannedCredentialResultUseCase(any(), documentType, any()) } returns mockScannedCredentialResult - viewModel.startScanning() // Initialises capture timing + viewModel.startScanning() + viewModel.processImage(bitmap, cropConfig) viewModel.processOcrResultsAndFinish() - val scannedCredential = finishObserver.value()?.peekContent() - assertThat(scannedCredential?.zoomedCredentialImagePath).isNull() - assertThat(scannedCredential?.credential).isEqualTo(mockTokenizedCredential) - assertThat(scannedCredential?.documentImagePath).isEqualTo(detectedBlockImagePath) + coVerify { buildScannedCredentialResultUseCase(listOf(mockScannedDocument), documentType, any()) } } @Test @@ -379,4 +324,31 @@ internal class ExternalCredentialScanOcrViewModelTest { val result = viewModel.getDocumentTypeRes() assertThat(result).isEqualTo(R.string.mfid_type_ghana_id_card) } + + @Test + 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 { scanMfidDocumentUseCase(mockCroppedBitmap, documentType, any()) } returns mockScannedDocument + coEvery { buildScannedCredentialResultUseCase(any(), documentType, any()) } returns mockScannedCredentialResult + + val stateObserver = viewModel.scanOcrStateLiveData.test() + + viewModel.startScanning() + // transitioning to Complete + viewModel.processOcrResultsAndFinish() + + // Frame from camera arrives after Complete + viewModel.imageProcessingStarted() + viewModel.processImage(bitmap, cropConfig) + + assertThat(stateObserver.value()).isEqualTo(ScanOcrState.Complete) + assertThat(viewModel.isOcrActive).isFalse() + coVerify(exactly = 0) { scanMfidDocumentUseCase.invoke(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 new file mode 100644 index 0000000000..0ccb95c7ef --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildMfidDocumentUseCaseTest.kt @@ -0,0 +1,256 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.asTokenizableRaw +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.model.ScannedMfidDocument +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +internal class BuildMfidDocumentUseCaseTest { + private val getBestReadout = mockk() + + private lateinit var useCase: BuildMfidDocumentUseCase + + @Before + fun setUp() { + every { getBestReadout(any(), any()) } answers { + firstArg>().first() + } + useCase = BuildMfidDocumentUseCase(getBestReadout) + } + + @Test + fun `returns GhanaNhisCard for NhisCard document type`() { + val documents = listOf(nhisScannedDocument()) + val result = useCase(documents, OcrDocumentType.NhisCard) + assertThat(result).isInstanceOf(MfidDocument.GhanaNhisCard::class.java) + } + + @Test + fun `returns GhanaIdCard for GhanaIdCard document type`() { + val documents = listOf(ghanaIdScannedDocument()) + val result = useCase(documents, OcrDocumentType.GhanaIdCard) + assertThat(result).isInstanceOf(MfidDocument.GhanaIdCard::class.java) + } + + @Test + fun `passes credential texts with nhis target length to best readout use case`() { + val credentialText = "12345678" + val documents = listOf(nhisScannedDocument(credential = credentialText)) + + useCase(documents, OcrDocumentType.NhisCard) + + verify { getBestReadout(listOf(credentialText), targetLength = NHIS_CREDENTIAL_LENGTH) } + } + + @Test + fun `passes credential texts with ghana id target length to best readout use case`() { + val credentialText = "GHA-123456789-0" + val documents = listOf(ghanaIdScannedDocument(credential = credentialText)) + + useCase(documents, OcrDocumentType.GhanaIdCard) + + verify { getBestReadout(listOf(credentialText), targetLength = GHANA_ID_CREDENTIAL_LENGTH) } + } + + @Test + fun `sets credential from best readout for NhisCard`() { + val expectedCredential = "12345678" + every { getBestReadout(listOf(expectedCredential), targetLength = NHIS_CREDENTIAL_LENGTH) } returns expectedCredential + val documents = listOf(nhisScannedDocument(credential = expectedCredential)) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.credential).isEqualTo(expectedCredential.asTokenizableRaw()) + } + + @Test + fun `sets credential from best readout for GhanaIdCard`() { + val expectedCredential = "GHA-123456789-0" + every { getBestReadout(listOf(expectedCredential), targetLength = GHANA_ID_CREDENTIAL_LENGTH) } returns expectedCredential + val documents = listOf(ghanaIdScannedDocument(credential = expectedCredential)) + + val result = useCase(documents, OcrDocumentType.GhanaIdCard) as MfidDocument.GhanaIdCard + + assertThat(result.credential).isEqualTo(expectedCredential.asTokenizableRaw()) + } + + @Test + fun `maps name field from nhis scan result`() { + val nameText = "JOHN DOE" + val documents = listOf(nhisScannedDocument(name = nameText)) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.name).isEqualTo(nameText.asTokenizableRaw()) + } + + @Test + fun `maps dateOfBirth field from nhis scan result`() { + val dob = "01/01/1990" + val documents = listOf(nhisScannedDocument(dateOfBirth = dob)) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.dateOfBirth).isEqualTo(dob.asTokenizableRaw()) + } + + @Test + fun `maps sex field from nhis scan result`() { + val sex = "M" + val documents = listOf(nhisScannedDocument(sex = sex)) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.sex).isEqualTo(sex.asTokenizableRaw()) + } + + @Test + fun `maps dateOfIssue field from nhis scan result`() { + val dateOfIssue = "01/01/2020" + val documents = listOf(nhisScannedDocument(dateOfIssue = dateOfIssue)) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.dateOfIssue).isEqualTo(dateOfIssue.asTokenizableRaw()) + } + + @Test + fun `returns null non-credential fields when nhis scan result has no fields`() { + val documents = listOf(nhisScannedDocument()) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.name).isNull() + assertThat(result.dateOfBirth).isNull() + assertThat(result.sex).isNull() + assertThat(result.dateOfIssue).isNull() + } + + @Test + fun `maps surname field from ghana id scan result`() { + val surname = "DOE" + val documents = listOf(ghanaIdScannedDocument(surname = surname)) + + val result = useCase(documents, OcrDocumentType.GhanaIdCard) as MfidDocument.GhanaIdCard + + assertThat(result.surname).isEqualTo(surname.asTokenizableRaw()) + } + + @Test + fun `maps firstName field from ghana id scan result`() { + val firstName = "JOHN" + val documents = listOf(ghanaIdScannedDocument(firstName = firstName)) + + val result = useCase(documents, OcrDocumentType.GhanaIdCard) as MfidDocument.GhanaIdCard + + assertThat(result.firstName).isEqualTo(firstName.asTokenizableRaw()) + } + + @Test + fun `returns null non-credential fields when ghana id scan result has no fields`() { + val documents = listOf(ghanaIdScannedDocument()) + + val result = useCase(documents, OcrDocumentType.GhanaIdCard) as MfidDocument.GhanaIdCard + + assertThat(result.surname).isNull() + assertThat(result.firstName).isNull() + assertThat(result.nationality).isNull() + assertThat(result.dateOfBirth).isNull() + assertThat(result.height).isNull() + assertThat(result.documentNumber).isNull() + assertThat(result.placeOfIssue).isNull() + assertThat(result.dateOfIssue).isNull() + assertThat(result.dateOfExpiry).isNull() + } + + @Test + fun `ignores non-matching scan result types when building nhis card fields`() { + val expectedName = "JANE DOE" + val nhisDocument = nhisScannedDocument(name = expectedName) + val ghanaIdDocument = ghanaIdScannedDocument() + val documents = listOf(nhisDocument, ghanaIdDocument) + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.name).isEqualTo(expectedName.asTokenizableRaw()) + } + + @Test + fun `aggregates credentials from multiple scans via best readout use case`() { + val bestReadout = "12345678" + val texts = listOf(bestReadout, "12345679", "12345678") + val documents = texts.map { nhisScannedDocument(credential = it) } + every { getBestReadout(texts, targetLength = NHIS_CREDENTIAL_LENGTH) } returns bestReadout + + val result = useCase(documents, OcrDocumentType.NhisCard) as MfidDocument.GhanaNhisCard + + assertThat(result.credential).isEqualTo(bestReadout.asTokenizableRaw()) + verify { getBestReadout(texts, targetLength = NHIS_CREDENTIAL_LENGTH) } + } + + private fun ocrLine(text: String) = OcrLine( + id = 0, + text = text, + boundingBox = BoundingBox(left = 0, top = 0, right = 100, bottom = 20), + blockBoundingBox = BoundingBox(left = 0, top = 0, right = 100, bottom = 20), + confidence = 1f, + ) + + private fun nhisScannedDocument( + credential: String = "12345678", + name: String? = null, + dateOfBirth: String? = null, + sex: String? = null, + dateOfIssue: String? = null, + ) = ScannedMfidDocument( + imagePath = "path/to/image.jpg", + ocrScanResult = OcrScanResult.GhanaNhisCard( + credential = ocrLine(credential), + name = name?.let { ocrLine(it) }, + dateOfBirth = dateOfBirth?.let { ocrLine(it) }, + sex = sex?.let { ocrLine(it) }, + dateOfIssue = dateOfIssue?.let { ocrLine(it) }, + ), + ) + + private fun ghanaIdScannedDocument( + credential: String = "GHA-123456789-0", + surname: String? = null, + firstName: String? = null, + nationality: String? = null, + dateOfBirth: String? = null, + height: String? = null, + documentNumber: String? = null, + placeOfIssue: String? = null, + dateOfIssue: String? = null, + dateOfExpiry: String? = null, + ) = ScannedMfidDocument( + imagePath = "path/to/image.jpg", + ocrScanResult = OcrScanResult.GhanaIdCard( + credential = ocrLine(credential), + surname = surname?.let { ocrLine(it) }, + firstName = firstName?.let { ocrLine(it) }, + nationality = nationality?.let { ocrLine(it) }, + dateOfBirth = dateOfBirth?.let { ocrLine(it) }, + height = height?.let { ocrLine(it) }, + documentNumber = documentNumber?.let { ocrLine(it) }, + placeOfIssue = placeOfIssue?.let { ocrLine(it) }, + dateOfIssue = dateOfIssue?.let { ocrLine(it) }, + dateOfExpiry = dateOfExpiry?.let { ocrLine(it) }, + ), + ) + + companion object { + private const val NHIS_CREDENTIAL_LENGTH = 8 + private const val GHANA_ID_CREDENTIAL_LENGTH = 15 + } +} 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 new file mode 100644 index 0000000000..ca092a949e --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildScannedCredentialResultUseCaseTest.kt @@ -0,0 +1,154 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp +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.model.ScannedMfidDocument +import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class BuildScannedCredentialResultUseCaseTest { + @MockK + private lateinit var buildMfidDocumentUseCase: BuildMfidDocumentUseCase + + @MockK + private lateinit var createAndSaveZoomedImageUseCase: CreateAndSaveZoomedImageUseCase + + @MockK + private lateinit var timeHelper: TimeHelper + + private lateinit var useCase: BuildScannedCredentialResultUseCase + + private val startTime = Timestamp(100L) + private val endTime = Timestamp(200L) + private val documentImagePath = "path/to/document.jpg" + private val zoomedImagePath = "path/to/zoomed.jpg" + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + useCase = BuildScannedCredentialResultUseCase( + buildMfidDocumentUseCase = buildMfidDocumentUseCase, + createAndSaveZoomedImageUseCase = createAndSaveZoomedImageUseCase, + timeHelper = timeHelper, + ) + every { timeHelper.now() } returns endTime + coEvery { createAndSaveZoomedImageUseCase(any(), any()) } returns zoomedImagePath + every { buildMfidDocumentUseCase(any(), any()) } returns mockk(relaxed = true) + } + + @Test + fun `delegates document building to buildMfidDocumentUseCase`() = runTest { + val documents = listOf(nhisScannedDocument()) + useCase(documents, OcrDocumentType.NhisCard, startTime) + coVerify(exactly = 1) { buildMfidDocumentUseCase(documents, OcrDocumentType.NhisCard) } + } + + @Test + fun `result document is from buildMfidDocumentUseCase`() = runTest { + val expectedDocument = mockk(relaxed = true) + every { buildMfidDocumentUseCase(any(), any()) } returns expectedDocument + val documents = listOf(nhisScannedDocument()) + + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + + assertThat(result.document).isEqualTo(expectedDocument) + } + + @Test + fun `result scan start time matches provided start time`() = runTest { + val documents = listOf(nhisScannedDocument()) + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + assertThat(result.scanStartTime).isEqualTo(startTime) + } + + @Test + fun `result scan end time comes from timeHelper`() = runTest { + val documents = listOf(nhisScannedDocument()) + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + assertThat(result.scanEndTime).isEqualTo(endTime) + } + + @Test + fun `result document image path is taken from last scanned document`() = runTest { + val lastFile = "last.jpg" + val firstDocument = nhisScannedDocument(imagePath = "first.jpg") + val lastDocument = nhisScannedDocument(imagePath = lastFile) + + val result = useCase(listOf(firstDocument, lastDocument), OcrDocumentType.NhisCard, startTime) + + assertThat(result.documentImagePath).isEqualTo(lastFile) + } + + @Test + fun `zoomed image is created using last document credential and image path`() = runTest { + val lastFile = "last.jpg" + val firstDocument = nhisScannedDocument(imagePath = "first.jpg") + val lastDocument = nhisScannedDocument(imagePath = lastFile) + + useCase(listOf(firstDocument, lastDocument), OcrDocumentType.NhisCard, startTime) + + coVerify { createAndSaveZoomedImageUseCase(lastDocument.ocrScanResult.credential, lastFile) } + } + + @Test + fun `result zoomed image path comes from createAndSaveZoomedImageUseCase`() = runTest { + val documents = listOf(nhisScannedDocument()) + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + assertThat(result.zoomedCredentialImagePath).isEqualTo(zoomedImagePath) + } + + @Test + fun `result credential bounding box taken from last document credential block`() = runTest { + val expectedBoundingBox = BoundingBox(left = 10, top = 20, right = 300, bottom = 50) + val documents = listOf(nhisScannedDocument(credentialBlockBoundingBox = expectedBoundingBox)) + + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + + assertThat(result.credentialBoundingBox).isEqualTo(expectedBoundingBox) + } + + @Test + fun `result zoomed image path is null when createAndSaveZoomedImageUseCase returns null`() = runTest { + coEvery { createAndSaveZoomedImageUseCase(any(), any()) } returns null + val documents = listOf(nhisScannedDocument()) + + val result = useCase(documents, OcrDocumentType.NhisCard, startTime) + + assertThat(result.zoomedCredentialImagePath).isNull() + } + + private fun ocrLine( + text: String = "12345678", + blockBoundingBox: BoundingBox? = null, + ) = OcrLine( + id = 0, + text = text, + boundingBox = BoundingBox(left = 0, top = 0, right = 100, bottom = 20), + blockBoundingBox = blockBoundingBox ?: BoundingBox(left = 0, top = 0, right = 100, bottom = 20), + confidence = 1f, + ) + + private fun nhisScannedDocument( + imagePath: String = documentImagePath, + credential: String = "12345678", + credentialBlockBoundingBox: BoundingBox? = null, + ) = ScannedMfidDocument( + imagePath = imagePath, + ocrScanResult = OcrScanResult.GhanaNhisCard( + credential = ocrLine(credential, credentialBlockBoundingBox), + ), + ) +} 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 index 6f6e034ec6..f7be7808bf 100644 --- 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 @@ -174,4 +174,20 @@ internal class CropDocumentFromPreviewUseCaseTest { } 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/FindBestTextBlockForCredentialUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCaseTest.kt deleted file mode 100644 index 867f2bb634..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCaseTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.google.common.truth.Truth.assertThat -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import io.mockk.verify -import org.junit.Before -import org.junit.Test - -internal class FindBestTextBlockForCredentialUseCaseTest { - @MockK - private lateinit var calculateLevenshteinDistanceUseCase: CalculateLevenshteinDistanceUseCase - - @MockK - private lateinit var useCase: FindBestTextBlockForCredentialUseCase - - private val targetCredential = "GHA-123456789-0" - private val exactMatch = "GHA-123456789-0" - private val closeMatch = "GHA-123456789-1" - private val distantMatch = "ABC-987654321-9" - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - useCase = FindBestTextBlockForCredentialUseCase(calculateLevenshteinDistanceUseCase) - } - - private fun createBlock(readoutValue: String) = mockk { - every { this@mockk.readoutValue } returns readoutValue - every { copy(readoutValue = any()) } returns this@mockk - } - - @Test(expected = IllegalArgumentException::class) - fun `throws exception when block list is empty`() { - val emptyBlocks = emptyList() - - useCase(targetCredential, emptyBlocks) - } - - @Test - fun `returns exact match when found`() { - val exactMatchBlock = createBlock(exactMatch) - val otherBlock = createBlock(closeMatch) - val blocks = listOf(otherBlock, exactMatchBlock) - - val result = useCase(targetCredential, blocks) - - assertThat(result).isEqualTo(exactMatchBlock) - verify(exactly = 0) { calculateLevenshteinDistanceUseCase(any(), any()) } - } - - @Test - fun `returns last exact match when multiple found`() { - val firstExactMatch = createBlock(exactMatch) - val secondExactMatch = createBlock(exactMatch) - val blocks = listOf(firstExactMatch, secondExactMatch) - - val result = useCase(targetCredential, blocks) - - assertThat(result).isEqualTo(secondExactMatch) - } - - @Test - fun `searches blocks in reverse order for exact match`() { - val firstBlock = createBlock(closeMatch) - val lastBlock = createBlock(exactMatch) - val blocks = listOf(firstBlock, lastBlock) - - val result = useCase(targetCredential, blocks) - - assertThat(result).isEqualTo(lastBlock) - } - - @Test - fun `uses levenshtein distance when no exact match found`() { - val closeBlock = createBlock(closeMatch) - val distantBlock = createBlock(distantMatch) - val blocks = listOf(distantBlock, closeBlock) - - every { calculateLevenshteinDistanceUseCase(targetCredential, closeMatch) } returns 1 - every { calculateLevenshteinDistanceUseCase(targetCredential, distantMatch) } returns 10 - - useCase(targetCredential, blocks) - - verify { calculateLevenshteinDistanceUseCase.invoke(targetCredential, closeMatch) } - verify { calculateLevenshteinDistanceUseCase.invoke(targetCredential, distantMatch) } - verify { closeBlock.copy(readoutValue = targetCredential) } - } - - @Test - fun `returns block with smallest levenshtein distance`() { - val closeBlock = createBlock(closeMatch) - val distantBlock = createBlock(distantMatch) - val blocks = listOf(closeBlock, distantBlock) - - every { calculateLevenshteinDistanceUseCase(targetCredential, closeMatch) } returns 1 - every { calculateLevenshteinDistanceUseCase(targetCredential, distantMatch) } returns 10 - - useCase(targetCredential, blocks) - - verify { closeBlock.copy(readoutValue = targetCredential) } - } - - @Test - fun `updates credential value when using levenshtein distance`() { - val block = createBlock(closeMatch) - val blocks = listOf(block) - val updatedBlock = mockk() - - every { calculateLevenshteinDistanceUseCase(targetCredential, closeMatch) } returns 1 - every { block.copy(readoutValue = targetCredential) } returns updatedBlock - - val result = useCase(targetCredential, blocks) - - assertThat(result).isEqualTo(updatedBlock) - verify { block.copy(readoutValue = targetCredential) } - } - - @Test - fun `handles single block with exact match`() { - val block = createBlock(exactMatch) - val blocks = listOf(block) - - val result = useCase(targetCredential, blocks) - - assertThat(result).isEqualTo(block) - verify(exactly = 0) { calculateLevenshteinDistanceUseCase.invoke(any(), any()) } - } - - @Test - fun `handles equal levenshtein distances by returning first found`() { - val firstBlock = createBlock(closeMatch) - val secondBlock = createBlock(distantMatch) - val blocks = listOf(firstBlock, secondBlock) - - every { calculateLevenshteinDistanceUseCase(targetCredential, closeMatch) } returns 1 - every { calculateLevenshteinDistanceUseCase(targetCredential, distantMatch) } returns 1 - - useCase(targetCredential, blocks) - - verify { firstBlock.copy(readoutValue = targetCredential) } - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt deleted file mode 100644 index 8803479d93..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCaseTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import android.graphics.Bitmap -import com.google.common.truth.Truth.assertThat -import com.simprints.feature.externalcredential.model.BoundingBox -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType -import com.simprints.feature.externalcredential.screens.scanocr.reader.OcrLine -import com.simprints.infra.credential.store.CredentialImageRepository -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -internal class GetCredentialCoordinatesUseCaseTest { - @MockK - private lateinit var readTextFromImage: ReadTextFromImageUseCase - - @MockK - private lateinit var ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase - - @MockK - private lateinit var ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase - - @MockK - private lateinit var credentialImageRepository: CredentialImageRepository - - private lateinit var useCase: GetCredentialCoordinatesUseCase - - private val bitmap = mockk(relaxed = true) - private val savedImagePath = "path/to/image.jpg" - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - useCase = GetCredentialCoordinatesUseCase( - readTextFromImage = readTextFromImage, - ghanaNhisCardOcrSelectorUseCase = ghanaNhisCardOcrSelectorUseCase, - ghanaIdCardOcrSelectorUseCase = ghanaIdCardOcrSelectorUseCase, - credentialImageRepository = credentialImageRepository, - ) - coEvery { credentialImageRepository.saveCredentialScan(any(), any()) } returns savedImagePath - } - - @Test - fun `returns null when readTextFromImage returns null`() = runTest { - every { readTextFromImage(bitmap) } returns null - - val result = useCase(bitmap, OcrDocumentType.NhisCard) - - assertThat(result).isNull() - } - - @Test - fun `returns null when selector finds no matching line for NhisCard`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaNhisCardOcrSelectorUseCase(any()) } returns null - - val result = useCase(bitmap, OcrDocumentType.NhisCard) - - assertThat(result).isNull() - } - - @Test - fun `returns null when selector finds no matching line for GhanaIdCard`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaIdCardOcrSelectorUseCase(any()) } returns null - - val result = useCase(bitmap, OcrDocumentType.GhanaIdCard) - - assertThat(result).isNull() - } - - @Test - fun `returns DetectedOcrBlock for NhisCard when line is found`() = runTest { - val ocrLine = ocrLine(text = "12345678") - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine - - val result = useCase(bitmap, OcrDocumentType.NhisCard) - - assertThat(result).isNotNull() - assertThat(result?.readoutValue).isEqualTo(ocrLine.text) - assertThat(result?.lineBoundingBox).isEqualTo(ocrLine.boundingBox) - assertThat(result?.blockBoundingBox).isEqualTo(ocrLine.blockBoundingBox) - assertThat(result?.documentType).isEqualTo(OcrDocumentType.NhisCard) - } - - @Test - fun `returns DetectedOcrBlock for GhanaIdCard when line is found`() = runTest { - val ocrLine = ocrLine(text = "GHA-123456789-0") - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaIdCardOcrSelectorUseCase(any()) } returns ocrLine - - val result = useCase(bitmap, OcrDocumentType.GhanaIdCard) - - assertThat(result).isNotNull() - assertThat(result?.readoutValue).isEqualTo(ocrLine.text) - assertThat(result?.documentType).isEqualTo(OcrDocumentType.GhanaIdCard) - } - - @Test - fun `saves image when line is found`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine() - - useCase(bitmap, OcrDocumentType.NhisCard) - - coVerify(exactly = 1) { credentialImageRepository.saveCredentialScan(bitmap, any()) } - } - - @Test - fun `does not save image when no line is found`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaNhisCardOcrSelectorUseCase(any()) } returns null - - useCase(bitmap, OcrDocumentType.NhisCard) - - coVerify(exactly = 0) { credentialImageRepository.saveCredentialScan(any(), any()) } - } - - @Test - fun `saved image path is set in result`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - every { ghanaNhisCardOcrSelectorUseCase(any()) } returns ocrLine() - - val result = useCase(bitmap, OcrDocumentType.NhisCard) - - assertThat(result?.imagePath).isEqualTo(savedImagePath) - } - - @Test - fun `returns null when exception is thrown`() = runTest { - every { readTextFromImage(bitmap) } throws RuntimeException("OCR failed") - - val result = useCase(bitmap, OcrDocumentType.NhisCard) - - assertThat(result).isNull() - } - - @Test - fun `delegates NhisCard to nhis selector`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - - useCase(bitmap, OcrDocumentType.NhisCard) - - coVerify(exactly = 1) { ghanaNhisCardOcrSelectorUseCase(any()) } - coVerify(exactly = 0) { ghanaIdCardOcrSelectorUseCase(any()) } - } - - @Test - fun `delegates GhanaIdCard to ghana id selector`() = runTest { - every { readTextFromImage(bitmap) } returns mockk(relaxed = true) - - useCase(bitmap, OcrDocumentType.GhanaIdCard) - - coVerify(exactly = 1) { ghanaIdCardOcrSelectorUseCase(any()) } - coVerify(exactly = 0) { ghanaNhisCardOcrSelectorUseCase(any()) } - } - - private fun ocrLine(text: String = "12345678") = OcrLine( - id = 0, - text = text, - boundingBox = BoundingBox(left = 0, top = 100, right = 200, bottom = 130), - blockBoundingBox = BoundingBox(left = 0, top = 90, right = 200, bottom = 140), - confidence = 1f, - ) -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCaseTest.kt index f01f85ad15..a0711d06a6 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCaseTest.kt @@ -1,15 +1,12 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import com.google.common.truth.Truth.assertThat -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.mockk import org.junit.Before import org.junit.Test internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { - private lateinit var useCase: GetExternalCredentialBasedOnConfidenceUseCase + private lateinit var useCase: GetBestReadoutBasedOnConfidenceUseCase private val credentialLength3 = 3 private val credentialLengthNhis = 8 @@ -18,19 +15,15 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - useCase = GetExternalCredentialBasedOnConfidenceUseCase() - } - - private fun createBlock(value: String) = mockk { - every { readoutValue } returns value + useCase = GetBestReadoutBasedOnConfidenceUseCase() } @Test fun `returns most frequent character at each position`() { val blocks = listOf( - createBlock("ABC"), - createBlock("ACD"), - createBlock("CCD"), + "ABC", + "ACD", + "CCD", ) val result = useCase(blocks, credentialLength3) @@ -41,7 +34,7 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Test fun `returns single value when only one block provided`() { val nhisMembership = "12345678" - val blocks = listOf(createBlock(nhisMembership)) + val blocks = listOf(nhisMembership) val result = useCase(blocks, credentialLengthNhis) @@ -51,9 +44,9 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Test fun `filters out blocks with different lengths`() { val blocks = listOf( - createBlock("ABCDE"), - createBlock("ACD"), - createBlock("ACDGH"), + "ABCDE", + "ACD", + "ACDGH", ) val result = useCase(blocks, credentialLength3) @@ -65,9 +58,9 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { fun `handles identical strings correctly`() { val nhisMembership = "12345678" val blocks = listOf( - createBlock(nhisMembership), - createBlock(nhisMembership), - createBlock(nhisMembership), + nhisMembership, + nhisMembership, + nhisMembership, ) val result = useCase(blocks, credentialLengthNhis) @@ -77,7 +70,7 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Test(expected = IllegalArgumentException::class) fun `throws exception when block list is empty`() { - val blocks = emptyList() + val blocks = emptyList() useCase(blocks, credentialLength3) } @@ -85,9 +78,9 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Test(expected = IllegalArgumentException::class) fun `throws exception when all blocks filtered out by length`() { val blocks = listOf( - createBlock("AB"), - createBlock("ABCD"), - createBlock("ABCDE"), + "AB", + "ABCD", + "ABCDE", ) useCase(blocks, credentialLength3) @@ -96,9 +89,9 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { @Test fun `handles single character strings`() { val blocks = listOf( - createBlock("A"), - createBlock("B"), - createBlock("A"), + "A", + "B", + "A", ) val result = useCase(blocks, 1) @@ -110,9 +103,9 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { fun `constructs credential from multiple varying positions`() { val ghanaId = "GHA-123456789-0" val blocks = listOf( - createBlock("GHA-123456789-0"), - createBlock("GHA-123456789-1"), - createBlock("GHA-123456789-0"), + "GHA-123456789-0", + "GHA-123456789-1", + "GHA-123456789-0", ) val result = useCase(blocks, credentialLengthGhanaID) @@ -124,10 +117,10 @@ internal class GetExternalCredentialBasedOnConfidenceUseCaseTest { fun `uses only blocks matching credential length`() { val targetLength = 5 val blocks = listOf( - createBlock("ABCDE"), - createBlock("FGHIJ"), - createBlock("AB"), - createBlock("ABCDEFGH"), + "ABCDE", + "FGHIJ", + "AB", + "ABCDEFGH", ) val result = useCase(blocks, targetLength) diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardCredentialOcrExtractorUseCaseTest.kt similarity index 85% rename from feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt rename to feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardCredentialOcrExtractorUseCaseTest.kt index 356f10d58e..28ef83b56b 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardCredentialOcrExtractorUseCaseTest.kt @@ -9,8 +9,8 @@ import io.mockk.MockKAnnotations import org.junit.Before import org.junit.Test -internal class GhanaIdCardOcrSelectorUseCaseTest { - private lateinit var useCase: GhanaIdCardOcrSelectorUseCase +internal class GhanaIdCardCredentialOcrExtractorUseCaseTest { + private lateinit var useCase: GhanaIdCardOcrReaderUseCase private val label = "Ghana Card Number" private val validIds = listOf( "GHA-123456789-0", @@ -32,7 +32,7 @@ internal class GhanaIdCardOcrSelectorUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - useCase = GhanaIdCardOcrSelectorUseCase() + useCase = GhanaIdCardOcrReaderUseCase() } @Test @@ -42,7 +42,7 @@ internal class GhanaIdCardOcrSelectorUseCaseTest { val expected = line(id = id + 1, text = ghanaId, top = 140) val reader = buildReader(nonMatching, expected) - assertThat(useCase(reader)).isEqualTo(expected) + assertThat(useCase(reader, isCapturingAllFields = false)?.credential).isEqualTo(expected) } } @@ -54,7 +54,7 @@ internal class GhanaIdCardOcrSelectorUseCaseTest { line(id = id + 1, text = ghanaId, top = 140), ) - assertThat(useCase(reader)).isNull() + assertThat(useCase(reader, isCapturingAllFields = false)).isNull() } } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt index 3db7f26b91..941c08f2e0 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCaseTest.kt @@ -10,7 +10,7 @@ import org.junit.Before import org.junit.Test internal class GhanaNhisCardOcrSelectorUseCaseTest { - private lateinit var useCase: GhanaNhisCardOcrSelectorUseCase + private lateinit var useCase: GhanaNhisCardOcrReaderUseCase private val label = "membership number" private val validNumbers = listOf( "12345678", @@ -28,7 +28,7 @@ internal class GhanaNhisCardOcrSelectorUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - useCase = GhanaNhisCardOcrSelectorUseCase() + useCase = GhanaNhisCardOcrReaderUseCase() } @Test @@ -38,7 +38,7 @@ internal class GhanaNhisCardOcrSelectorUseCaseTest { val expected = line(id = id + 1, text = number, top = 140) val reader = buildReader(label, expected) - assertThat(useCase(reader)).isEqualTo(expected) + assertThat(useCase(reader, isCapturingAllFields = false)?.credential).isEqualTo(expected) } } @@ -50,7 +50,7 @@ internal class GhanaNhisCardOcrSelectorUseCaseTest { line(id = id + 1, text = number, top = 140), ) - assertThat(useCase(reader)).isNull() + assertThat(useCase(reader, isCapturingAllFields = false)).isNull() } } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCaseTest.kt deleted file mode 100644 index 0a21921094..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCaseTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanocr.usecase - -import com.google.common.truth.Truth.* -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType -import com.simprints.infra.credential.store.CredentialImageRepository -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -internal class KeepOnlyBestDetectedBlockUseCaseTest { - @MockK - private lateinit var getExternalCredentialBasedOnConfidenceUseCase: GetExternalCredentialBasedOnConfidenceUseCase - - @MockK - private lateinit var findBestTextBlockForCredentialUseCase: FindBestTextBlockForCredentialUseCase - - @MockK - private lateinit var credentialImageRepository: CredentialImageRepository - - private lateinit var useCase: KeepOnlyBestDetectedBlockUseCase - - private val bestBlockImagePath = "/path/to/best/image.jpg" - private val otherBlockImagePath1 = "/path/to/other1/image.jpg" - private val otherBlockImagePath2 = "/path/to/other2/image.jpg" - private val credentialNhis = "12345678" - private val credentialGhanaId = "GHA-123456789-0" - private val credentialLengthNhis = credentialNhis.length - private val credentialLengthGhanaID = credentialGhanaId.length - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - useCase = KeepOnlyBestDetectedBlockUseCase( - getExternalCredentialBasedOnConfidenceUseCase = getExternalCredentialBasedOnConfidenceUseCase, - findBestTextBlockForCredentialUseCase = findBestTextBlockForCredentialUseCase, - credentialImageRepository = credentialImageRepository, - ) - } - - private fun createMockBlock(imagePath: String) = mockk { - every { this@mockk.imagePath } returns imagePath - } - - @Test - fun `returns best block and deletes other cached images for NHIS card`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val otherBlock1 = createMockBlock(otherBlockImagePath1) - val otherBlock2 = createMockBlock(otherBlockImagePath2) - val allBlocks = listOf(bestBlock, otherBlock1, otherBlock2) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthNhis) } returns credentialNhis - every { findBestTextBlockForCredentialUseCase(credentialNhis, allBlocks) } returns bestBlock - - val result = useCase(allBlocks, OcrDocumentType.NhisCard) - - assertThat(result).isEqualTo(bestBlock) - coVerify { credentialImageRepository.deleteByPath(otherBlockImagePath1) } - coVerify { credentialImageRepository.deleteByPath(otherBlockImagePath2) } - coVerify(exactly = 0) { credentialImageRepository.deleteByPath(bestBlockImagePath) } - } - - @Test - fun `returns best block and deletes other cached images for Ghana ID card`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val otherBlock1 = createMockBlock(otherBlockImagePath1) - val otherBlock2 = createMockBlock(otherBlockImagePath2) - val allBlocks = listOf(bestBlock, otherBlock1, otherBlock2) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthGhanaID) } returns credentialGhanaId - every { findBestTextBlockForCredentialUseCase(credentialGhanaId, allBlocks) } returns bestBlock - - val result = useCase(allBlocks, OcrDocumentType.GhanaIdCard) - - assertThat(result).isEqualTo(bestBlock) - coVerify { credentialImageRepository.deleteByPath(otherBlockImagePath1) } - coVerify { credentialImageRepository.deleteByPath(otherBlockImagePath2) } - coVerify(exactly = 0) { credentialImageRepository.deleteByPath(bestBlockImagePath) } - } - - @Test - fun `uses correct credential length for NHIS card`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val allBlocks = listOf(bestBlock) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthNhis) } returns credentialNhis - every { findBestTextBlockForCredentialUseCase(credentialNhis, allBlocks) } returns bestBlock - - useCase(allBlocks, OcrDocumentType.NhisCard) - - verify { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthNhis) } - } - - @Test - fun `uses correct credential length for Ghana ID card`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val allBlocks = listOf(bestBlock) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthGhanaID) } returns credentialGhanaId - every { findBestTextBlockForCredentialUseCase(credentialGhanaId, allBlocks) } returns bestBlock - - useCase(allBlocks, OcrDocumentType.GhanaIdCard) - - verify { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthGhanaID) } - } - - @Test - fun `does not delete any images when only one block exists`() = runTest { - val singleBlock = createMockBlock(bestBlockImagePath) - val allBlocks = listOf(singleBlock) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthNhis) } returns credentialNhis - every { findBestTextBlockForCredentialUseCase(credentialNhis, allBlocks) } returns singleBlock - - val result = useCase(allBlocks, OcrDocumentType.NhisCard) - - assertThat(result).isEqualTo(singleBlock) - coVerify(exactly = 0) { credentialImageRepository.deleteByPath(any()) } - } - - @Test - fun `passes credential to find best block use case`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val allBlocks = listOf(bestBlock) - - every { getExternalCredentialBasedOnConfidenceUseCase(allBlocks, credentialLengthNhis) } returns credentialNhis - every { findBestTextBlockForCredentialUseCase(credentialNhis, allBlocks) } returns bestBlock - - useCase(allBlocks, OcrDocumentType.NhisCard) - - verify { findBestTextBlockForCredentialUseCase(credentialNhis, allBlocks) } - } - - @Test - fun `deletes multiple other images but keeps best block image`() = runTest { - val bestBlock = createMockBlock(bestBlockImagePath) - val blocks = listOf( - createMockBlock("/path1.jpg"), - createMockBlock("/path2.jpg"), - bestBlock, - createMockBlock("/path3.jpg"), - createMockBlock("/path4.jpg"), - ) - val mockCredential = "GHA-123456789-0" - - every { getExternalCredentialBasedOnConfidenceUseCase(blocks, credentialLengthGhanaID) } returns mockCredential - every { findBestTextBlockForCredentialUseCase(mockCredential, blocks) } returns bestBlock - - useCase(blocks, OcrDocumentType.GhanaIdCard) - - coVerify { credentialImageRepository.deleteByPath("/path1.jpg") } - coVerify { credentialImageRepository.deleteByPath("/path2.jpg") } - coVerify { credentialImageRepository.deleteByPath("/path3.jpg") } - coVerify { credentialImageRepository.deleteByPath("/path4.jpg") } - coVerify(exactly = 0) { credentialImageRepository.deleteByPath(bestBlockImagePath) } - } -} 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 new file mode 100644 index 0000000000..d0b5df3c8a --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ScanMfidDocumentUseCaseTest.kt @@ -0,0 +1,217 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import com.google.common.truth.Truth.assertThat +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.infra.config.store.models.GhanaIdCardConfig +import com.simprints.infra.config.store.models.MultiFactorIdConfiguration +import com.simprints.infra.config.store.models.NhisCardConfig +import com.simprints.infra.credential.store.CredentialImageRepository +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class ScanMfidDocumentUseCaseTest { + @MockK + private lateinit var readTextFromImage: ReadTextFromImageUseCase + + @MockK + private lateinit var ghanaNhisCardOcrReaderUseCase: GhanaNhisCardOcrReaderUseCase + + @MockK + private lateinit var ghanaIdCardOcrReaderUseCase: GhanaIdCardOcrReaderUseCase + + @MockK + private lateinit var credentialImageRepository: CredentialImageRepository + + private lateinit var useCase: ScanMfidDocumentUseCase + + private val bitmap = mockk(relaxed = true) + private val savedImagePath = "path/to/image.jpg" + private val nhisConfig = MultiFactorIdConfiguration( + allowedExternalCredentials = emptyList(), + ghanaIdCardConfig = null, + nhisCardConfig = NhisCardConfig(isCapturingAllFields = false), + qrCodeConfig = null, + ) + private val ghanaIdConfig = MultiFactorIdConfiguration( + allowedExternalCredentials = emptyList(), + ghanaIdCardConfig = GhanaIdCardConfig(isCapturingAllFields = false), + nhisCardConfig = null, + qrCodeConfig = null, + ) + private val allFieldsConfig = MultiFactorIdConfiguration( + allowedExternalCredentials = emptyList(), + ghanaIdCardConfig = GhanaIdCardConfig(isCapturingAllFields = true), + nhisCardConfig = NhisCardConfig(isCapturingAllFields = true), + qrCodeConfig = null, + ) + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + useCase = ScanMfidDocumentUseCase( + readTextFromImage = readTextFromImage, + ghanaNhisCardOcrReaderUseCase = ghanaNhisCardOcrReaderUseCase, + ghanaIdCardOcrReaderUseCase = ghanaIdCardOcrReaderUseCase, + credentialImageRepository = credentialImageRepository, + ) + coEvery { credentialImageRepository.saveCredentialScan(any(), any()) } returns savedImagePath + } + + @Test + fun `returns null when readTextFromImage returns null`() = runTest { + every { readTextFromImage(bitmap) } returns null + val result = useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + assertThat(result).isNull() + } + + @Test + fun `returns null when selector finds no matching line for NhisCard`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrReaderUseCase(any(), any()) } returns null + val result = useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + assertThat(result).isNull() + } + + @Test + fun `returns null when selector finds no matching line for GhanaIdCard`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaIdCardOcrReaderUseCase(any(), any()) } returns null + + val result = useCase(bitmap, OcrDocumentType.GhanaIdCard, ghanaIdConfig) + + assertThat(result).isNull() + } + + @Test + fun `returns ScannedMfidDocument for NhisCard when line is found`() = runTest { + val credentialLine = ocrLine(text = "12345678") + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrReaderUseCase(any(), any()) } returns OcrScanResult.GhanaNhisCard(credential = credentialLine) + + val result = useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + + assertThat(result).isNotNull() + assertThat(result?.ocrScanResult?.credential?.text).isEqualTo(credentialLine.text) + assertThat(result?.ocrScanResult?.credential?.boundingBox).isEqualTo(credentialLine.boundingBox) + assertThat(result?.ocrScanResult).isInstanceOf(OcrScanResult.GhanaNhisCard::class.java) + } + + @Test + fun `returns ScannedMfidDocument for GhanaIdCard when line is found`() = runTest { + val credentialLine = ocrLine(text = "GHA-123456789-0") + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaIdCardOcrReaderUseCase(any(), any()) } returns OcrScanResult.GhanaIdCard(credential = credentialLine) + + val result = useCase(bitmap, OcrDocumentType.GhanaIdCard, ghanaIdConfig) + + assertThat(result).isNotNull() + assertThat(result?.ocrScanResult?.credential?.text).isEqualTo(credentialLine.text) + assertThat(result?.ocrScanResult).isInstanceOf(OcrScanResult.GhanaIdCard::class.java) + } + + @Test + fun `saves image when line is found`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrReaderUseCase(any(), any()) } returns OcrScanResult.GhanaNhisCard(credential = ocrLine()) + + useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + + coVerify(exactly = 1) { credentialImageRepository.saveCredentialScan(bitmap, any()) } + } + + @Test + fun `does not save image when no line is found`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrReaderUseCase(any(), any()) } returns null + + useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + + coVerify(exactly = 0) { credentialImageRepository.saveCredentialScan(any(), any()) } + } + + @Test + fun `saved image path is set in result`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + every { ghanaNhisCardOcrReaderUseCase(any(), any()) } returns OcrScanResult.GhanaNhisCard(credential = ocrLine()) + + val result = useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + + assertThat(result?.imagePath).isEqualTo(savedImagePath) + } + + @Test + fun `returns null when exception is thrown`() = runTest { + every { readTextFromImage(bitmap) } throws RuntimeException("OCR failed") + val result = useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + assertThat(result).isNull() + } + + @Test + fun `delegates NhisCard to nhis selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.NhisCard, nhisConfig) + + verify(exactly = 1) { ghanaNhisCardOcrReaderUseCase(any(), any()) } + verify(exactly = 0) { ghanaIdCardOcrReaderUseCase(any(), any()) } + } + + @Test + fun `delegates GhanaIdCard to ghana id selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.GhanaIdCard, ghanaIdConfig) + + verify(exactly = 1) { ghanaIdCardOcrReaderUseCase(any(), any()) } + verify(exactly = 0) { ghanaNhisCardOcrReaderUseCase(any(), any()) } + } + + @Test + fun `passes isCapturingAllFields from nhis config to selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + useCase(bitmap, OcrDocumentType.NhisCard, allFieldsConfig) + verify { ghanaNhisCardOcrReaderUseCase(any(), isCapturingAllFields = true) } + } + + @Test + fun `passes isCapturingAllFields from ghana id config to selector`() = runTest { + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + useCase(bitmap, OcrDocumentType.GhanaIdCard, allFieldsConfig) + verify { ghanaIdCardOcrReaderUseCase(any(), isCapturingAllFields = true) } + } + + @Test + fun `passes false for isCapturingAllFields when config is absent for nhis`() = runTest { + val configWithoutNhis = MultiFactorIdConfiguration( + allowedExternalCredentials = emptyList(), + ghanaIdCardConfig = null, + nhisCardConfig = null, + qrCodeConfig = null, + ) + every { readTextFromImage(bitmap) } returns mockk(relaxed = true) + + useCase(bitmap, OcrDocumentType.NhisCard, configWithoutNhis) + + verify { ghanaNhisCardOcrReaderUseCase(any(), isCapturingAllFields = false) } + } + + private fun ocrLine(text: String = "12345678") = OcrLine( + id = 0, + text = text, + boundingBox = BoundingBox(left = 0, top = 100, right = 200, bottom = 130), + blockBoundingBox = BoundingBox(left = 0, top = 90, right = 200, bottom = 140), + confidence = 1f, + ) +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt index db50576748..3f459c2be3 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt @@ -4,15 +4,10 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.* import com.jraska.livedata.test import com.simprints.core.domain.permission.PermissionStatus -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanqr.usecase.ExternalCredentialQrCodeValidatorUseCase -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK @@ -34,12 +29,6 @@ internal class ExternalCredentialScanQrViewModelTest { @MockK private lateinit var validator: ExternalCredentialQrCodeValidatorUseCase - @MockK - private lateinit var tokenizationProcessor: TokenizationProcessor - - @MockK - private lateinit var configRepository: ConfigRepository - private lateinit var viewModel: ExternalCredentialScanQrViewModel @Before @@ -48,8 +37,6 @@ internal class ExternalCredentialScanQrViewModelTest { viewModel = ExternalCredentialScanQrViewModel( timeHelper = timeHelper, externalCredentialQrCodeValidator = validator, - tokenizationProcessor = tokenizationProcessor, - configRepository = configRepository, ) every { timeHelper.now() } returns Timestamp(1L) @@ -68,35 +55,18 @@ internal class ExternalCredentialScanQrViewModelTest { assertThat(observer.value()).isEqualTo(ScanQrState.ReadyToScan) } - @Test - fun `updateCapturedValue with missing project`() = runTest { - val observer = viewModel.stateLiveData.test() - val value = "value" - - coEvery { configRepository.getProject() } returns null - viewModel.updateCapturedValue(value) - - assertThat(observer.value()).isEqualTo(ScanQrState.ReadyToScan) - } - @Test fun `updateCapturedValue with non-null sets QrCodeCaptured`() = runTest { val observer = viewModel.stateLiveData.test() val value = "value" - val mockProject = mockk() - val mockTokenizedCredential = mockk() - - coEvery { configRepository.getProject() } returns mockProject - every { tokenizationProcessor.encrypt(any(), TokenKeyType.ExternalCredential, mockProject) } returns mockTokenizedCredential - viewModel.updateCameraPermissionStatus(permissionStatus = PermissionStatus.Granted) // inits the capture timing + viewModel.updateCameraPermissionStatus(permissionStatus = PermissionStatus.Granted) viewModel.updateCapturedValue(value) val expected = ScanQrState.QrCodeCaptured( scanStartTime = Timestamp(1L), scanEndTime = Timestamp(1L), qrCode = value.asTokenizableRaw(), - qrCodeEncrypted = mockTokenizedCredential, ) assertThat(observer.value()).isEqualTo(expected) } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt index d6ce437b0f..95e5051491 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt @@ -10,11 +10,10 @@ import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.model.ExternalCredentialParams -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaIdCardOcrSelectorUseCase -import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState import com.simprints.feature.externalcredential.screens.search.usecase.MatchCandidatesUseCase @@ -63,7 +62,7 @@ internal class ExternalCredentialSearchViewModelTest { lateinit var candidateMatch: CredentialMatch @MockK - lateinit var mockScannedCredential: ScannedCredential + lateinit var mockScannedCredentialResult: ScannedCredentialResult @MockK lateinit var externalCredentialParams: ExternalCredentialParams @@ -77,12 +76,6 @@ internal class ExternalCredentialSearchViewModelTest { @MockK lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase - @MockK - lateinit var ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase - - @MockK - lateinit var ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase - private lateinit var viewModel: ExternalCredentialSearchViewModel @Before @@ -93,10 +86,11 @@ internal class ExternalCredentialSearchViewModelTest { coEvery { configRepository.getProjectConfiguration() } returns projectConfig coJustRun { eventsTracker.saveSearchEvent(any(), any(), any()) } coJustRun { eventsTracker.saveConfirmation(any(), any()) } + every { tokenizationProcessor.encrypt(any(), any(), any()) } returns mockk() } fun createViewModel() = ExternalCredentialSearchViewModel( - scannedCredential = mockScannedCredential, + scannedCredentialResult = mockScannedCredentialResult, externalCredentialParams = externalCredentialParams, timeHelper = timeHelper, configRepository = configRepository, @@ -104,8 +98,6 @@ internal class ExternalCredentialSearchViewModelTest { tokenizationProcessor = tokenizationProcessor, enrolmentRecordRepository = enrolmentRecordRepository, eventsTracker = eventsTracker, - ghanaIdValidationUseCase = ghanaIdValidationUseCase, - ghanaNhisCardValidationUseCase = ghanaNhisCardValidationUseCase, ) @Test @@ -115,30 +107,25 @@ internal class ExternalCredentialSearchViewModelTest { viewModel = createViewModel() - verify(exactly = 0) { tokenizationProcessor.decrypt(any(), any(), any()) } coVerify(exactly = 0) { enrolmentRecordRepository.load(any()) } } @Test fun `initial state starts searching when credential not found`() = runTest { - val decryptedCredential = mockk() - coEvery { tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, project) } returns decryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns emptyList() viewModel = createViewModel() val observer = viewModel.stateLiveData.test() assertThat(observer.value()?.searchState).isEqualTo(SearchState.CredentialNotFound) - assertThat(observer.value()?.scannedCredential).isEqualTo(mockScannedCredential) + assertThat(observer.value()?.scannedCredentialResult).isEqualTo(mockScannedCredentialResult) assertThat(observer.value()?.isConfirmed).isFalse() - assertThat(observer.value()?.displayedCredential).isEqualTo(decryptedCredential) + assertThat(observer.value()?.displayedCredential).isEqualTo(mockScannedCredentialResult.credential) coVerify { eventsTracker.saveSearchEvent(any(), any(), any()) } } @Test fun `initial state searches and finds linked credential`() = runTest { - val decryptedCredential = mockk() - coEvery { tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, project) } returns decryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns listOf(enrolmentRecord) coEvery { matchCandidatesUseCase(any(), any(), any(), any(), any()) @@ -154,8 +141,6 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `updateConfirmation updates isConfirmed state`() = runTest { - val decryptedCredential = mockk() - coEvery { tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, project) } returns decryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns emptyList() viewModel = createViewModel() @@ -180,11 +165,9 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `confirmCredentialUpdate triggers new search and encrypts credential`() = runTest { - val decryptedCredential = mockk() val newCredential = "newCredential".asTokenizableRaw() val encryptedCredential = mockk() - coEvery { tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, project) } returns decryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns emptyList() every { tokenizationProcessor.encrypt(newCredential, TokenKeyType.ExternalCredential, project) } returns encryptedCredential @@ -227,21 +210,21 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `getKeyBoardInputType returns number for NHIS card`() = runTest { - every { mockScannedCredential.credentialType } returns ExternalCredentialType.NHISCard + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.NHISCard val result = createViewModel().getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_NUMBER) } @Test fun `getKeyBoardInputType returns text for Ghana ID card`() = runTest { - every { mockScannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.GhanaIdCard val result = createViewModel().getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_TEXT) } @Test fun `getKeyBoardInputType returns text for QR code`() = runTest { - every { mockScannedCredential.credentialType } returns ExternalCredentialType.QRCode + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.QRCode val result = createViewModel().getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_TEXT) } @@ -250,44 +233,47 @@ internal class ExternalCredentialSearchViewModelTest { fun `finish sends empty matches when credential not found`() = runTest { viewModel = createViewModel() val state = mockk { - every { scannedCredential } returns mockScannedCredential + every { scannedCredentialResult } returns mockScannedCredentialResult + every { displayedCredential } returns mockk() every { searchState } returns SearchState.CredentialNotFound } viewModel.finish(state) - val finishEvent = viewModel.finishEvent.value?.peekContent() + val finishEvent = viewModel.finishEvent.value?.peekContent() as ExternalCredentialSearchResult.Complete assertThat(finishEvent).isNotNull() - assertThat(finishEvent?.matchResults).isEmpty() - assertThat(finishEvent?.scannedCredential).isEqualTo(mockScannedCredential) + assertThat(finishEvent.matchResults).isEmpty() + assertThat(finishEvent.scannedCredentialResult).isEqualTo(mockScannedCredentialResult) } @Test fun `finish sends empty matches when still searching`() = runTest { viewModel = createViewModel() val state = mockk { - every { scannedCredential } returns mockScannedCredential + every { scannedCredentialResult } returns mockScannedCredentialResult + every { displayedCredential } returns mockk() every { searchState } returns SearchState.Searching } viewModel.finish(state) - val finishEvent = viewModel.finishEvent.value?.peekContent() + val finishEvent = viewModel.finishEvent.value?.peekContent() as ExternalCredentialSearchResult.Complete assertThat(finishEvent).isNotNull() - assertThat(finishEvent?.matchResults).isEmpty() + assertThat(finishEvent.matchResults).isEmpty() } @Test fun `finish sends match results when credential linked`() = runTest { val state = mockk { - every { scannedCredential } returns mockScannedCredential + every { scannedCredentialResult } returns mockScannedCredentialResult + every { displayedCredential } returns mockk() every { searchState } returns mockk { every { matchResults } returns listOf(candidateMatch) } } viewModel = createViewModel() viewModel.finish(state) - val finishEvent = viewModel.finishEvent.value?.peekContent() + val finishEvent = viewModel.finishEvent.value?.peekContent() as ExternalCredentialSearchResult.Complete assertThat(finishEvent).isNotNull() - assertThat(finishEvent?.matchResults).hasSize(1) - assertThat(finishEvent?.matchResults?.first()).isEqualTo(candidateMatch) - assertThat(finishEvent?.scannedCredential).isEqualTo(mockScannedCredential) + assertThat(finishEvent.matchResults).hasSize(1) + assertThat(finishEvent.matchResults.first()).isEqualTo(candidateMatch) + assertThat(finishEvent.scannedCredentialResult).isEqualTo(mockScannedCredentialResult) } @Test @@ -300,7 +286,8 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `finish sends confirmation event`() = runTest { val state = mockk { - every { scannedCredential } returns mockScannedCredential + every { scannedCredentialResult } returns mockScannedCredentialResult + every { displayedCredential } returns mockk() every { searchState } returns mockk { every { matchResults } returns listOf(candidateMatch) } @@ -312,66 +299,47 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `decryptCredentialToDisplay updates displayedCredential state`() = runTest { - val encryptedCredential = mockk() - val decryptedCredential = "decryptedValue".asTokenizableRaw() + val credential = mockk() - every { mockScannedCredential.credential } returns encryptedCredential - coEvery { tokenizationProcessor.decrypt(encryptedCredential, TokenKeyType.ExternalCredential, project) } returns decryptedCredential + every { mockScannedCredentialResult.credential } returns credential coEvery { enrolmentRecordRepository.load(any()) } returns emptyList() viewModel = createViewModel() - assertThat(viewModel.stateLiveData.value?.displayedCredential).isEqualTo(decryptedCredential) - coVerify { tokenizationProcessor.decrypt(encryptedCredential, TokenKeyType.ExternalCredential, project) } + assertThat(viewModel.stateLiveData.value?.displayedCredential).isEqualTo(credential) } @Test fun `isCredentialFormatValid validates NHIS card format`() = runTest { - val validNhisCard = "12345678" - val invalidNhisCard = "invalid" - - every { mockScannedCredential.credentialType } returns ExternalCredentialType.NHISCard - every { ghanaNhisCardValidationUseCase(validNhisCard) } returns true - every { ghanaNhisCardValidationUseCase(invalidNhisCard) } returns false + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.NHISCard viewModel = createViewModel() - assertThat(viewModel.isCredentialFormatValid(validNhisCard)).isTrue() - assertThat(viewModel.isCredentialFormatValid(invalidNhisCard)).isFalse() + assertThat(viewModel.isCredentialFormatValid("12345678")).isTrue() + assertThat(viewModel.isCredentialFormatValid("invalid")).isFalse() + assertThat(viewModel.isCredentialFormatValid("1234567")).isFalse() assertThat(viewModel.isCredentialFormatValid(null)).isFalse() - verify { ghanaNhisCardValidationUseCase(validNhisCard) } - verify { ghanaNhisCardValidationUseCase(invalidNhisCard) } } @Test fun `isCredentialFormatValid validates Ghana ID card format`() = runTest { - val validGhanaIdCard = "GHA-12345789-0" - val invalidGhanaIdCard = "invalid" - - every { mockScannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard - every { ghanaIdValidationUseCase(validGhanaIdCard) } returns true - every { ghanaIdValidationUseCase(invalidGhanaIdCard) } returns false + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.GhanaIdCard viewModel = createViewModel() - assertThat(viewModel.isCredentialFormatValid(validGhanaIdCard)).isTrue() - assertThat(viewModel.isCredentialFormatValid(invalidGhanaIdCard)).isFalse() + assertThat(viewModel.isCredentialFormatValid("GHA-123456789-0")).isTrue() + assertThat(viewModel.isCredentialFormatValid("invalid")).isFalse() assertThat(viewModel.isCredentialFormatValid(null)).isFalse() - verify { ghanaIdValidationUseCase(validGhanaIdCard) } - verify { ghanaIdValidationUseCase(invalidGhanaIdCard) } } @Test fun `isCredentialFormatValid always returns true for QR code`() = runTest { - val anyValue = "any_value" - val emptyValue = "" - - every { mockScannedCredential.credentialType } returns ExternalCredentialType.QRCode + every { mockScannedCredentialResult.credentialType } returns ExternalCredentialType.QRCode viewModel = createViewModel() - assertThat(viewModel.isCredentialFormatValid(anyValue)).isTrue() - assertThat(viewModel.isCredentialFormatValid(emptyValue)).isTrue() + assertThat(viewModel.isCredentialFormatValid("any_value")).isTrue() + assertThat(viewModel.isCredentialFormatValid("")).isTrue() assertThat(viewModel.isCredentialFormatValid(null)).isFalse() } } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt deleted file mode 100644 index bffc349cd3..0000000000 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.simprints.feature.externalcredential.screens.search.model - -import com.google.common.truth.Truth.* -import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.core.domain.tokenization.asTokenizableRaw -import com.simprints.core.tools.time.Timestamp -import org.junit.Test - -class ScannedCredentialTest { - private val testUuid = "testUuid" - private val subjectId = "subjectId" - - @Test - fun `toExternalCredential maps fields correctly`() { - val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val scannedCredential = ScannedCredential( - credentialScanId = testUuid, - credential = tokenizedValue, - credentialType = ExternalCredentialType.NHISCard, - documentImagePath = null, - zoomedCredentialImagePath = null, - credentialBoundingBox = null, - scanStartTime = Timestamp(1L), - scanEndTime = Timestamp(2L), - scannedValue = "".asTokenizableRaw(), - ) - - val result = scannedCredential.toExternalCredential(subjectId) - - assertThat(result.id).isEqualTo(testUuid) - assertThat(result.value).isEqualTo(tokenizedValue) - assertThat(result.subjectId).isEqualTo(subjectId) - assertThat(result.type).isEqualTo(ExternalCredentialType.NHISCard) - } -} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt index fa76ce28f5..15e764fd1f 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt @@ -1,21 +1,24 @@ package com.simprints.feature.externalcredential.usecase import com.google.common.truth.Truth.* +import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.comparison.ComparisonResult +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.ExternalCredentialMapper +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.screens.scanocr.usecase.CalculateLevenshteinDistanceUseCase -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.FingerprintConfiguration import com.simprints.infra.config.store.models.ModalitySdkType import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent @@ -38,15 +41,15 @@ class ExternalCredentialEventTrackerUseCaseTest { @MockK private lateinit var configRepository: ConfigRepository - @MockK - private lateinit var tokenizationProcessor: TokenizationProcessor - @MockK private lateinit var eventRepository: SessionEventRepository @MockK private lateinit var calculateDistance: CalculateLevenshteinDistanceUseCase + @MockK + private lateinit var externalCredentialMapper: ExternalCredentialMapper + private lateinit var useCase: ExternalCredentialEventTrackerUseCase @Before @@ -55,37 +58,42 @@ class ExternalCredentialEventTrackerUseCaseTest { useCase = ExternalCredentialEventTrackerUseCase( timeHelper = timeHelper, configRepository = configRepository, - tokenizationProcessor = tokenizationProcessor, eventRepository = eventRepository, calculateDistance = calculateDistance, + externalCredentialMapper = externalCredentialMapper, ) every { timeHelper.now() } returns END_TIME coEvery { configRepository.getProject() } returns mockk() coEvery { - tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, any()) - } returns RAW_SCANNED_VALUE.asTokenizableRaw() + externalCredentialMapper.mapExternalCredential(any(), any()) + } returns ExternalCredential( + id = SCAN_ID, + value = ENCRYPTED_CREDENTIAL, + subjectId = SUBJECT_ID, + type = ExternalCredentialType.QRCode, + ) coEvery { calculateDistance(any(), any()) } returns DEFAULT_DISTANCE } @Test - fun `saveCaptureEvents should save external credential capture events`() = runTest { - val scannedCredential = makeScannedCredential(ExternalCredentialType.QRCode) - useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) - - val valueEventSlot = slot() - coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(valueEventSlot)) } - with(valueEventSlot.captured) { - assertThat(payload.credential.id).isEqualTo(SCAN_ID) - assertThat(payload.credential.subjectId).isEqualTo(SUBJECT_ID) - } + fun `saveCaptureEvents should save external credential capture value event`() = runTest { + val searchResult = makeCredentialSearchResult(ExternalCredentialType.QRCode) + useCase.saveCaptureEvents(searchResult, SUBJECT_ID, START_TIME, SELECTION_ID) + + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(any()) } + } + + @Test + fun `saveCaptureEvents should save external credential capture event`() = runTest { + val searchResult = makeCredentialSearchResult(ExternalCredentialType.QRCode) + useCase.saveCaptureEvents(searchResult, SUBJECT_ID, START_TIME, SELECTION_ID) val captureEventSlot = slot() coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } with(captureEventSlot.captured) { - assertThat(payload.id).isEqualTo(SCAN_ID) assertThat(payload.createdAt).isEqualTo(START_TIME) assertThat(payload.endedAt).isEqualTo(END_TIME) assertThat(payload.autoCaptureStartTime).isEqualTo(SCAN_START_TIME) @@ -95,25 +103,10 @@ class ExternalCredentialEventTrackerUseCaseTest { } } - @Test - fun `saveCaptureEvents should handle missing project in capture events`() = runTest { - clearMocks(configRepository) - coEvery { configRepository.getProject() } returns null - - val scannedCredential = makeScannedCredential(ExternalCredentialType.QRCode) - useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) - - val captureEventSlot = slot() - coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } - with(captureEventSlot.captured) { - assertThat(payload.ocrErrorCount).isEqualTo(0) - } - } - @Test fun `saveCaptureEvents should correctly calculate length for NHISCard`() = runTest { - val scannedCredential = makeScannedCredential(ExternalCredentialType.NHISCard) - useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + val searchResult = makeCredentialSearchResult(ExternalCredentialType.NHISCard) + useCase.saveCaptureEvents(searchResult, SUBJECT_ID, START_TIME, SELECTION_ID) val captureEventSlot = slot() coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } @@ -122,8 +115,8 @@ class ExternalCredentialEventTrackerUseCaseTest { @Test fun `saveCaptureEvents should correctly calculate length for GhanaIdCard`() = runTest { - val scannedCredential = makeScannedCredential(ExternalCredentialType.GhanaIdCard) - useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + val searchResult = makeCredentialSearchResult(ExternalCredentialType.GhanaIdCard) + useCase.saveCaptureEvents(searchResult, SUBJECT_ID, START_TIME, SELECTION_ID) val captureEventSlot = slot() coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } @@ -132,8 +125,8 @@ class ExternalCredentialEventTrackerUseCaseTest { @Test fun `saveCaptureEvents should correctly calculate length for QRCode`() = runTest { - val scannedCredential = makeScannedCredential(ExternalCredentialType.QRCode) - useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + val searchResult = makeCredentialSearchResult(ExternalCredentialType.QRCode) + useCase.saveCaptureEvents(searchResult, SUBJECT_ID, START_TIME, SELECTION_ID) val captureEventSlot = slot() coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } @@ -226,17 +219,28 @@ class ExternalCredentialEventTrackerUseCaseTest { ) } - private fun makeScannedCredential(type: ExternalCredentialType) = ScannedCredential( - credentialScanId = "test-scan-id", - credential = RAW_SCANNED_VALUE.asTokenizableEncrypted(), - credentialType = type, - documentImagePath = null, - zoomedCredentialImagePath = null, - credentialBoundingBox = null, - scanStartTime = SCAN_START_TIME, - scanEndTime = SCAN_END_TIME, - scannedValue = RAW_SCANNED_VALUE.asTokenizableRaw(), - ) + private fun makeCredentialSearchResult(type: ExternalCredentialType): ExternalCredentialSearchResult.Complete { + val document: MfidDocument = when (type) { + ExternalCredentialType.NHISCard -> MfidDocument.GhanaNhisCard(credential = RAW_SCANNED_VALUE.asTokenizableRaw()) + ExternalCredentialType.GhanaIdCard -> MfidDocument.GhanaIdCard(credential = RAW_SCANNED_VALUE.asTokenizableRaw()) + ExternalCredentialType.QRCode -> MfidDocument.GhanaQrCode(credential = RAW_SCANNED_VALUE.asTokenizableRaw()) + } + val scannedResult = ScannedCredentialResult( + credentialScanId = SCAN_ID, + document = document, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = SCAN_START_TIME, + scanEndTime = SCAN_END_TIME, + ) + return ExternalCredentialSearchResult.Complete( + flowType = FlowType.ENROL, + scannedCredentialResult = scannedResult, + confirmedCredential = RAW_SCANNED_VALUE.asTokenizableRaw(), + matchResults = emptyList(), + ) + } private fun makeCredentialMatch( faceSdk: ModalitySdkType?, @@ -249,7 +253,7 @@ class ExternalCredentialEventTrackerUseCaseTest { val sdk = faceSdk ?: fingerprintSdk!! return CredentialMatch( - credential = RAW_SCANNED_VALUE.asTokenizableEncrypted(), + credential = ENCRYPTED_CREDENTIAL, comparisonResult = matchResult, probeReferenceId = PROBE_REFERENCE_ID, verificationThreshold = 0.5f, @@ -283,12 +287,12 @@ class ExternalCredentialEventTrackerUseCaseTest { private val END_TIME = Timestamp(6L) private const val SCAN_ID = "test-scan-id" private const val SUBJECT_ID = "test-subject-id" - private const val RAW_SCANNED_VALUE = "scanned-value" + private const val RAW_SCANNED_VALUE = "scanned" + private val ENCRYPTED_CREDENTIAL = "encrypted_credential".asTokenizableEncrypted() private const val DEFAULT_DISTANCE = 7 private const val SELECTION_ID = "selection_id" private const val CONFIDENCE = 0.9f private const val PROBE_REFERENCE_ID = "probe-ref-id" - private const val MATCHER_NAME = "matcher-name" private val FACE_SDK = ModalitySdkType.RANK_ONE private val FINGERPRINT_SDK = ModalitySdkType.SECUGEN_SIM_MATCHER } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt index 27afaec1da..3ab7d7d40e 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ResetExternalCredentialsInSessionUseCaseTest.kt @@ -1,11 +1,12 @@ package com.simprints.feature.externalcredential.usecase import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.common.truth.Truth.* +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialMapper +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Project import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository @@ -14,8 +15,13 @@ import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import com.simprints.infra.events.session.SessionEventRepository import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.slot import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Before @@ -39,10 +45,22 @@ internal class ResetExternalCredentialsInSessionUseCaseTest { lateinit var project: Project @MockK - lateinit var scannedCredential: ScannedCredential + lateinit var eventRepository: SessionEventRepository @MockK - lateinit var eventRepository: SessionEventRepository + lateinit var externalCredentialMapper: ExternalCredentialMapper + + @MockK + lateinit var credentialSearchResult: ExternalCredentialSearchResult.Complete + + @MockK + lateinit var mappedCredential: ExternalCredential + + @MockK + lateinit var enrolmentUpdateEvent: EnrolmentUpdateEvent + + @MockK + lateinit var otherEvent: ExternalCredentialSelectionEvent private lateinit var useCase: ResetExternalCredentialsInSessionUseCase @@ -52,138 +70,199 @@ internal class ResetExternalCredentialsInSessionUseCaseTest { coEvery { configRepository.getProject() } returns project + coEvery { + externalCredentialMapper.mapExternalCredential( + searchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) + } returns mappedCredential + + every { enrolmentUpdateEvent.payload.subjectId } returns PREVIOUS_SUBJECT_ID + every { enrolmentUpdateEvent.payload.externalCredentialIdsToAdd } returns + listOf(PREVIOUS_CREDENTIAL_ID) + + every { mappedCredential.id } returns SCAN_ID + every { mappedCredential.subjectId } returns SUBJECT_ID + every { mappedCredential.type } returns CREDENTIAL_TYPE + every { mappedCredential.value } returns ENCRYPTED_CREDENTIAL + useCase = ResetExternalCredentialsInSessionUseCase( enrolmentRecordRepository = enrolmentRecordRepository, configRepository = configRepository, eventRepository = eventRepository, + credentialMapper = externalCredentialMapper, sessionCoroutineScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ) - every { scannedCredential.credential } returns CREDENTIAL - every { scannedCredential.credentialType } returns CREDENTIAL_TYE } @Test fun `invokes enrolment repository with correct update action`() = runTest { coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() - useCase(scannedCredential, SUBJECT_ID) - - val actionsSlot = slot>() - coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + useCase( + credentialSearchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) - val actions = actionsSlot.captured + val actions = captureActions() assertThat(actions).hasSize(1) val updateAction = actions.first() as EnrolmentRecordAction.Update assertThat(updateAction.subjectId).isEqualTo(SUBJECT_ID) - assertThat(updateAction.externalCredentialsToAdd).hasSize(1) + assertThat(updateAction.externalCredentialsToAdd) + .containsExactly(mappedCredential) assertThat(updateAction.samplesToAdd).isEmpty() assertThat(updateAction.referenceIdsToRemove).isEmpty() } @Test - fun `adds correct external credential to subject`() = runTest { - coEvery { eventRepository.getEventsInCurrentSession() } returns listOf() - - useCase(scannedCredential, SUBJECT_ID) + fun `uses mapped external credential from mapper`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() - val actionsSlot = slot>() - coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } + useCase( + credentialSearchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) - val updateAction = actionsSlot.captured.first() as EnrolmentRecordAction.Update - val addedCredential = updateAction.externalCredentialsToAdd.first() - assertThat(addedCredential.value).isEqualTo(CREDENTIAL) - assertThat(addedCredential.type).isEqualTo(CREDENTIAL_TYE) + val actions = captureActions() + val updateAction = actions.first() as EnrolmentRecordAction.Update + assertThat(updateAction.externalCredentialsToAdd) + .containsExactly(mappedCredential) + coVerify(exactly = 1) { + externalCredentialMapper.mapExternalCredential( + searchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) + } } @Test fun `handles missing project`() = runTest { clearMocks(configRepository) + coEvery { configRepository.getProject() } returns null - coEvery { eventRepository.getEventsInCurrentSession() } returns listOf() + coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() - useCase(scannedCredential, SUBJECT_ID) + useCase( + credentialSearchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) - coVerify(exactly = 0) { enrolmentRecordRepository.performActions(any(), any()) } + coVerify(exactly = 0) { + enrolmentRecordRepository.performActions(any(), any()) + } } @Test - fun `removes correct external credential to subject`() = runTest { + fun `removes correct external credential from previously linked subject`() = runTest { coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( - enrolmentUpdateEvent("subject-1", listOf("credentia-1")), + enrolmentUpdateEvent, ) useCase( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = SUBJECT_ID, ) - val actionsSlot = slot>() - coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } - - // Remove actions come first - val removeAction = actionsSlot.captured.first() as EnrolmentRecordAction.Update - assertThat(removeAction.subjectId).isEqualTo("subject-1") + val actions = captureActions() + val removeAction = actions.first() as EnrolmentRecordAction.Update + assertThat(removeAction.subjectId).isEqualTo(PREVIOUS_SUBJECT_ID) assertThat(removeAction.externalCredentialsToAdd).isEmpty() - assertThat(removeAction.externalCredentialIdsToRemove).containsExactly("credentia-1") - // Additions come after - val addAction = actionsSlot.captured.last() as EnrolmentRecordAction.Update - assertThat(addAction.externalCredentialsToAdd).isNotEmpty() + assertThat(removeAction.externalCredentialIdsToRemove) + .containsExactly(PREVIOUS_CREDENTIAL_ID) + val addAction = actions.last() as EnrolmentRecordAction.Update + assertThat(addAction.externalCredentialsToAdd) + .containsExactly(mappedCredential) assertThat(addAction.externalCredentialIdsToRemove).isEmpty() } @Test fun `remove existing update events in the session`() = runTest { coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( - otherEvent(), - enrolmentUpdateEvent("subject-1", listOf("credentia-1")), - otherEvent(), + otherEvent, + enrolmentUpdateEvent, + otherEvent, ) useCase( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = SUBJECT_ID, ) - coEvery { eventRepository.deleteEvents(match { it.size == 1 }) } + coVerify { + eventRepository.deleteEvents(match { it.size == 1 }) + } } @Test - fun `does not add credentials to any subject if no subjectID`() = runTest { + fun `does not add credentials when subjectId is not a valid UUID`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() + useCase( - scannedCredential = scannedCredential, - subjectId = "none_selected", + credentialSearchResult = credentialSearchResult, + subjectId = INVALID_SUBJECT_ID, ) - val actionsSlot = slot>() - coVerify { enrolmentRecordRepository.performActions(capture(actionsSlot), project) } - - assertThat(actionsSlot.captured).isEmpty() + val actions = captureActions() + assertThat(actions).isEmpty() + coVerify(exactly = 0) { + externalCredentialMapper.mapExternalCredential(any(), any()) + } } @Test fun `retrieves project using correct project id`() = runTest { - useCase(scannedCredential, SUBJECT_ID) + coEvery { eventRepository.getEventsInCurrentSession() } returns emptyList() + + useCase( + credentialSearchResult = credentialSearchResult, + subjectId = SUBJECT_ID, + ) + coVerify { configRepository.getProject() } } - private fun enrolmentUpdateEvent( - subjectId: String, - credentialIds: List, - ) = EnrolmentUpdateEvent( - createdAt = Timestamp(0L), - subjectId = subjectId, - externalCredentialIdsToAdd = credentialIds, - ) - - private fun otherEvent() = ExternalCredentialSelectionEvent( - Timestamp(0L), - Timestamp(1L), - CREDENTIAL_TYE, - ) + @Test + fun `invokes with null credentialSearchResult only removes existing links`() = runTest { + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf( + enrolmentUpdateEvent, + ) + + useCase( + credentialSearchResult = null, + subjectId = SUBJECT_ID, + ) + + val actions = captureActions() + assertThat(actions).hasSize(1) + val removeAction = actions.first() as EnrolmentRecordAction.Update + assertThat(removeAction.subjectId).isEqualTo(PREVIOUS_SUBJECT_ID) + assertThat(removeAction.externalCredentialIdsToRemove) + .containsExactly(PREVIOUS_CREDENTIAL_ID) + coVerify(exactly = 0) { + externalCredentialMapper.mapExternalCredential(any(), any()) + } + } + + private fun captureActions(): List { + val actionsSlot = slot>() + + coVerify { + enrolmentRecordRepository.performActions(capture(actionsSlot), project) + } + + return actionsSlot.captured + } companion object { private const val SUBJECT_ID = "bbaa8ff3-34f7-41d3-a6c9-ff3b952d832e" - private val CREDENTIAL = "credential".asTokenizableEncrypted() - private val CREDENTIAL_TYE = ExternalCredentialType.NHISCard + private const val INVALID_SUBJECT_ID = "none_selected" + private const val PREVIOUS_SUBJECT_ID = "subject-1" + private const val PREVIOUS_CREDENTIAL_ID = "credential-1" + private const val SCAN_ID = "scan-id" + + private val ENCRYPTED_CREDENTIAL = + "encrypted_credential".asTokenizableEncrypted() + + private val CREDENTIAL_TYPE = ExternalCredentialType.NHISCard } } diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt index 335009f932..d4c592ad88 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/OrchestratorViewModel.kt @@ -84,12 +84,12 @@ internal class OrchestratorViewModel @Inject constructor( // and add new ones to the list. This way all session steps are available throughout // the app for reference (i.e. have we already captured face in this session?) val cachedSteps = cache.steps - val cachedExternalCredentialResponse = getCachedCredentialResponse(cachedSteps) + val cachedCredentialSearchResult = getCachedCredentialSearchResult(cachedSteps) steps = cachedSteps + stepsBuilder.build( action = action, projectConfiguration = projectConfiguration, enrolmentSubjectId = enrolmentSubjectId, - cachedScannedCredential = cachedExternalCredentialResponse?.scannedCredential, + cachedCredentialSearchResult = cachedCredentialSearchResult, ) Simber.i("Steps to execute: ${steps.joinToString { it.id.toString() }}", tag = ORCHESTRATION) } catch (_: SubjectAgeNotSupportedException) { @@ -100,10 +100,10 @@ internal class OrchestratorViewModel @Inject constructor( doNextStep() } - private fun getCachedCredentialResponse(steps: List): ExternalCredentialSearchResult? { + private fun getCachedCredentialSearchResult(steps: List): ExternalCredentialSearchResult.Complete? { steps.forEach { step -> if (step.id == StepId.EXTERNAL_CREDENTIAL) { - return step.result as? ExternalCredentialSearchResult + return step.result as? ExternalCredentialSearchResult.Complete } } return null @@ -140,7 +140,7 @@ internal class OrchestratorViewModel @Inject constructor( steps = steps + captureAndMatchSteps } - if (result is ExternalCredentialSearchResult) { + if (result is ExternalCredentialSearchResult.Complete) { removeMatcherStepIfRequired(result) } @@ -197,7 +197,7 @@ internal class OrchestratorViewModel @Inject constructor( /** * Removes Matcher steps during [FlowType.IDENTIFY] flow if External Credential search found any match. */ - private fun removeMatcherStepIfRequired(result: ExternalCredentialSearchResult) { + private fun removeMatcherStepIfRequired(result: ExternalCredentialSearchResult.Complete) { if (result.flowType == FlowType.IDENTIFY) { val confirmedVerifications = result.goodMatches.size if (confirmedVerifications > 0) { @@ -219,13 +219,13 @@ internal class OrchestratorViewModel @Inject constructor( val updatedParams = params.copy( steps = mapStepsForLastBiometrics(steps.mapNotNull { it.result }), ) - val cachedExternalCredentialResponse = getCachedCredentialResponse(cache.steps) + val cachedExternalCredentialResponse = getCachedCredentialSearchResult(cache.steps) step.params = EnrolLastBiometricContract.getParams( projectId = updatedParams.projectId, userId = updatedParams.userId, moduleId = updatedParams.moduleId, steps = updatedParams.steps, - scannedCredential = cachedExternalCredentialResponse?.scannedCredential, + credentialSearchResult = cachedExternalCredentialResponse, ) } } diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt index 2a4446962b..ec4458882e 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/steps/Step.kt @@ -80,7 +80,8 @@ val orchestratorSerializersModule = SerializersModule { subclass(ExitFormResult::class) subclass(ValidateSubjectPoolResult::class) subclass(SelectSubjectAgeGroupResult::class) - subclass(ExternalCredentialSearchResult::class) + subclass(ExternalCredentialSearchResult.Complete::class) + subclass(ExternalCredentialSearchResult.Skipped::class) subclass(CredentialMatch::class) subclass(BiometricTemplateCapture::class) subclass(BiometricReferenceCapture::class) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCase.kt index 31a5b78bd2..86249e43ed 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCase.kt @@ -13,7 +13,7 @@ import javax.inject.Inject internal class MapStepsForLastBiometricEnrolUseCase @Inject constructor() { operator fun invoke(results: List) = results.mapNotNull { result -> when (result) { - is ExternalCredentialSearchResult -> result.matchResults.takeUnless { it.isEmpty() }?.let { credentialMatches -> + is ExternalCredentialSearchResult.Complete -> result.matchResults.takeUnless { it.isEmpty() }?.let { credentialMatches -> EnrolLastBiometricStepResult.MatchResult( results = credentialMatches.map { credentialMatch -> ComparisonResult(credentialMatch.comparisonResult.subjectId, credentialMatch.comparisonResult.comparisonScore) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapper.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapper.kt new file mode 100644 index 0000000000..da06b75d40 --- /dev/null +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapper.kt @@ -0,0 +1,66 @@ +package com.simprints.feature.orchestrator.usecases.response + +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.infra.orchestration.data.responses.AppExternalCredential + +internal fun ExternalCredentialSearchResult.Complete?.toAppExternalCredential(): AppExternalCredential? { + if (this == null) return null + val nonCredentialFields: Map = when (val document = scannedCredentialResult.document) { + is MfidDocument.GhanaIdCard -> document.toNonCredentialFields() + is MfidDocument.GhanaNhisCard -> document.toNonCredentialFields() + is MfidDocument.GhanaQrCode -> emptyMap() + } + return AppExternalCredential( + id = scannedCredentialResult.credentialScanId, + value = confirmedCredential, + type = scannedCredentialResult.credentialType, + nonCredentialFields = nonCredentialFields, + ) +} + +private fun MfidDocument.GhanaNhisCard.toNonCredentialFields(): Map = buildMap { + putIfPresent(GhanaNhisCardFields.NAME, name) + putIfPresent(GhanaNhisCardFields.DATE_OF_BIRTH, dateOfBirth) + putIfPresent(GhanaNhisCardFields.SEX, sex) + putIfPresent(GhanaNhisCardFields.DATE_OF_ISSUE, dateOfIssue) +} + +private fun MfidDocument.GhanaIdCard.toNonCredentialFields(): Map = buildMap { + putIfPresent(GhanaIdCardFields.SURNAME, surname) + putIfPresent(GhanaIdCardFields.FIRST_NAME, firstName) + putIfPresent(GhanaIdCardFields.NATIONALITY, nationality) + putIfPresent(GhanaIdCardFields.DATE_OF_BIRTH, dateOfBirth) + putIfPresent(GhanaIdCardFields.HEIGHT, height) + putIfPresent(GhanaIdCardFields.DOCUMENT_NUMBER, documentNumber) + putIfPresent(GhanaIdCardFields.PLACE_OF_ISSUE, placeOfIssue) + putIfPresent(GhanaIdCardFields.DATE_OF_ISSUE, dateOfIssue) + putIfPresent(GhanaIdCardFields.DATE_OF_EXPIRY, dateOfExpiry) +} + +private fun MutableMap.putIfPresent( + key: String, + value: TokenizableString.Raw?, +) { + value?.let { put(key, it.value) } +} + +private object GhanaNhisCardFields { + const val NAME = "name" + const val DATE_OF_BIRTH = "dateOfBirth" + const val SEX = "sex" + const val DATE_OF_ISSUE = "dateOfIssue" +} + +private object GhanaIdCardFields { + const val SURNAME = "surname" + const val FIRST_NAME = "firstName" + const val NATIONALITY = "nationality" + const val DATE_OF_BIRTH = "dateOfBirth" + const val HEIGHT = "height" + const val DOCUMENT_NUMBER = "documentNumber" + const val PLACE_OF_ISSUE = "placeOfIssue" + const val DATE_OF_ISSUE = "dateOfIssue" + const val DATE_OF_EXPIRY = "dateOfExpiry" +} diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCase.kt index 8328c40ee9..d6a96b5137 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCase.kt @@ -12,6 +12,6 @@ internal class CreateConfirmIdentityResponseUseCase @Inject constructor() { operator fun invoke(results: List): AppResponse = results .filterIsInstance() .lastOrNull() - ?.let { AppConfirmationResponse(true, externalCredential = it.savedCredential) } + ?.let { AppConfirmationResponse(true, externalCredential = it.credentialSearchResult.toAppExternalCredential()) } ?: AppErrorResponse(AppErrorReason.UNEXPECTED_ERROR) } diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolLastBiometricResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolLastBiometricResponseUseCase.kt index b6b6412383..01aa38c956 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolLastBiometricResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolLastBiometricResponseUseCase.kt @@ -13,7 +13,7 @@ internal class CreateEnrolLastBiometricResponseUseCase @Inject constructor() { .lastOrNull() ?.let { result -> result.newSubjectId?.let { guid -> - AppEnrolResponse(guid, result.externalCredential) + AppEnrolResponse(guid = guid, externalCredential = result.credentialSearchResult.toAppExternalCredential()) } } ?: AppErrorResponse(AppErrorReason.ENROLMENT_LAST_BIOMETRICS_FAILED) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCase.kt index fe5954d9f3..14a4eab472 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCase.kt @@ -2,8 +2,8 @@ package com.simprints.feature.orchestrator.usecases.response import com.simprints.core.domain.capture.BiometricReferenceCapture import com.simprints.core.domain.response.AppErrorReason +import com.simprints.feature.externalcredential.ExternalCredentialMapper import com.simprints.feature.externalcredential.ExternalCredentialSearchResult -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.models.Project import com.simprints.infra.eventsync.sync.common.EnrolmentRecordFactory import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION @@ -18,6 +18,7 @@ import javax.inject.Inject internal class CreateEnrolResponseUseCase @Inject constructor( private val enrolmentRecordFactory: EnrolmentRecordFactory, private val enrolRecord: EnrolRecordUseCase, + private val credentialMapper: ExternalCredentialMapper, ) { suspend operator fun invoke( request: ActionRequest.EnrolActionRequest, @@ -25,8 +26,8 @@ internal class CreateEnrolResponseUseCase @Inject constructor( project: Project, enrolmentSubjectId: String, ): AppResponse { - val credentialResult = results.filterIsInstance().lastOrNull() - val externalCredential = credentialResult?.scannedCredential?.toExternalCredential(enrolmentSubjectId) + val credentialSearchResult = results.filterIsInstance().lastOrNull() + val externalCredential = credentialSearchResult?.let { credentialMapper.mapExternalCredential(it, enrolmentSubjectId) } return try { val record = enrolmentRecordFactory.buildFromCaptureResults( @@ -39,7 +40,7 @@ internal class CreateEnrolResponseUseCase @Inject constructor( ) enrolRecord(record, project) - AppEnrolResponse(record.subjectId, externalCredential) + AppEnrolResponse(record.subjectId, credentialSearchResult?.toAppExternalCredential()) } catch (e: Exception) { Simber.e("Error creating enrol response", e, tag = ORCHESTRATION) AppErrorResponse(AppErrorReason.UNEXPECTED_ERROR) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt index 723743f5f6..e2dd801162 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt @@ -1,19 +1,13 @@ package com.simprints.feature.orchestrator.usecases.response import com.simprints.core.domain.response.AppMatchConfidence -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.ExternalCredentialSearchResult -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.config.store.models.ModalitySdkType -import com.simprints.infra.config.store.models.DecisionPolicy import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.getModalitySdkConfig -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.matching.MatchResult -import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse import com.simprints.infra.orchestration.data.responses.AppMatchResult import com.simprints.infra.orchestration.data.responses.AppResponse @@ -22,7 +16,6 @@ import javax.inject.Inject internal class CreateIdentifyResponseUseCase @Inject constructor( private val eventRepository: SessionEventRepository, - private val tokenizationProcessor: TokenizationProcessor, ) { suspend operator fun invoke( projectConfiguration: ProjectConfiguration, @@ -33,10 +26,9 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( val currentSessionId = eventRepository.getCurrentSessionScope().id val externalCredential = results - .filterIsInstance(ExternalCredentialSearchResult::class.java) + .filterIsInstance() .lastOrNull() - ?.scannedCredential - ?.toAppExternalCredential(tokenizationProcessor, project) + ?.toAppExternalCredential() return AppIdentifyResponse( sessionId = currentSessionId, @@ -47,23 +39,6 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( ) } - private fun ScannedCredential.toAppExternalCredential( - tokenizationProcessor: TokenizationProcessor, - project: Project?, - ): AppExternalCredential? { - if (project == null) return null - val decryptedValue = tokenizationProcessor.decrypt( - encrypted = credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as? TokenizableString.Raw ?: return null - return AppExternalCredential( - id = credentialScanId, - value = decryptedValue, - type = credentialType, - ) - } - /** * Combines all of the matching results per SDK and returns up to [maxNbOfReturnedCandidates] results from the SDK with * the highest overall score in descending order. Credential matches take precedence over direct matches. @@ -119,7 +94,7 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( results: List, projectConfiguration: ProjectConfiguration, ): Map> = results - .filterIsInstance() + .filterIsInstance() // Mapping the result to the common final type and pairing it with the sdk for later grouping .flatMap { credentialSearchResult -> credentialSearchResult.matchResults.mapNotNull { credentialMatchResult -> diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCase.kt index b5b0aa97b2..64c4e8a215 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCase.kt @@ -18,7 +18,7 @@ internal class IsNewEnrolmentUseCase @Inject constructor() { ): Boolean { val hasCredentialMatchResults = results - .filterIsInstance() + .filterIsInstance() .firstOrNull() ?.matchResults ?.isNotEmpty() ?: false diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt index 4da252c18a..8c0b66d7b3 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCase.kt @@ -8,7 +8,7 @@ import com.simprints.feature.consent.ConsentContract import com.simprints.feature.consent.ConsentType import com.simprints.feature.enrollast.EnrolLastBiometricContract import com.simprints.feature.externalcredential.ExternalCredentialContract -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.fetchsubject.FetchSubjectContract import com.simprints.feature.orchestrator.R import com.simprints.feature.orchestrator.cache.OrchestratorCache @@ -44,7 +44,7 @@ internal class BuildStepsUseCase @Inject constructor( action: ActionRequest, projectConfiguration: ProjectConfiguration, enrolmentSubjectId: String, - cachedScannedCredential: ScannedCredential?, + cachedCredentialSearchResult: ExternalCredentialSearchResult.Complete?, ) = when (action) { is ActionRequest.EnrolActionRequest -> { listOf( @@ -95,13 +95,13 @@ internal class BuildStepsUseCase @Inject constructor( is ActionRequest.EnrolLastBiometricActionRequest -> { listOf( - buildEnrolLastBiometricStep(action, projectConfiguration, cachedScannedCredential), + buildEnrolLastBiometricStep(action, projectConfiguration, cachedCredentialSearchResult), ) } is ActionRequest.ConfirmIdentityActionRequest -> { listOf( - buildConfirmIdentityStep(action, cachedScannedCredential), + buildConfirmIdentityStep(action, cachedCredentialSearchResult), ) } }.flatten() @@ -499,7 +499,7 @@ internal class BuildStepsUseCase @Inject constructor( private fun buildEnrolLastBiometricStep( action: ActionRequest.EnrolLastBiometricActionRequest, projectConfiguration: ProjectConfiguration, - cachedScannedCredential: ScannedCredential?, + cachedCredentialSearchResult: ExternalCredentialSearchResult.Complete?, ): List { // Get capture steps needed for enrolment val enrolCaptureSteps = buildCaptureSteps( @@ -525,7 +525,7 @@ internal class BuildStepsUseCase @Inject constructor( userId = action.userId, moduleId = action.moduleId, steps = mapStepsForLastBiometrics(cache.steps.mapNotNull { it.result }), - scannedCredential = cachedScannedCredential, + credentialSearchResult = cachedCredentialSearchResult, ), ) ) @@ -533,7 +533,7 @@ internal class BuildStepsUseCase @Inject constructor( private fun buildConfirmIdentityStep( action: ActionRequest.ConfirmIdentityActionRequest, - cachedScannedCredential: ScannedCredential?, + cachedCredentialSearchResult: ExternalCredentialSearchResult.Complete?, ) = listOf( Step( id = StepId.CONFIRM_IDENTITY, @@ -542,7 +542,7 @@ internal class BuildStepsUseCase @Inject constructor( params = SelectSubjectContract.getParams( projectId = action.projectId, subjectId = action.selectedGuid, - scannedCredential = cachedScannedCredential, + scannedCredentialResult = cachedCredentialSearchResult, ), ), ) diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt index 2545041194..c00ddbe5a7 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/OrchestratorViewModelTest.kt @@ -18,7 +18,7 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.orchestrator.cache.OrchestratorCache import com.simprints.feature.orchestrator.exceptions.SubjectAgeNotSupportedException import com.simprints.feature.orchestrator.steps.MatchStepStubPayload @@ -405,7 +405,7 @@ internal class OrchestratorViewModelTest { userId = TokenizableString.Tokenized("userId"), moduleId = TokenizableString.Tokenized("moduleId"), steps = listOf(mockk()), - scannedCredential = null, + credentialSearchResult = null, ) coEvery { stepsBuilder.build(any(), any(), any(), any()) } returns listOf( captureStep, @@ -481,7 +481,7 @@ internal class OrchestratorViewModelTest { @Test fun `Removes matcher steps when external credential search has good matches in identify flow`() = runTest { - val externalCredentialResult = mockk { + val externalCredentialResult = mockk(relaxed = true) { every { flowType } returns FlowType.IDENTIFY every { goodMatches } returns listOf(mockk()) } @@ -505,7 +505,7 @@ internal class OrchestratorViewModelTest { @Test fun `Does not remove matcher steps when flow type is enrol even with good matches`() = runTest { - val externalCredentialResult = mockk { + val externalCredentialResult = mockk(relaxed = true) { every { flowType } returns FlowType.ENROL every { goodMatches } returns listOf(mockk()) } @@ -527,10 +527,7 @@ internal class OrchestratorViewModelTest { @Test fun `Passes cached scanned credential to steps builder when external credential step exists in cache`() = runTest { - val mockScannedCredential = mockk(relaxed = true) - val externalCredentialResult = mockk { - every { scannedCredential } returns mockScannedCredential - } + val externalCredentialResult = mockk(relaxed = true) val externalCredentialStep = createMockStep(StepId.EXTERNAL_CREDENTIAL).apply { status = StepStatus.COMPLETED @@ -554,7 +551,7 @@ internal class OrchestratorViewModelTest { action = any(), projectConfiguration = any(), enrolmentSubjectId = any(), - cachedScannedCredential = mockScannedCredential, + cachedCredentialSearchResult = externalCredentialResult, ) } } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt index 1708628d9c..2ff82fa900 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt @@ -10,7 +10,6 @@ import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.common.Modality import com.simprints.core.domain.common.TemplateIdentifier import com.simprints.core.domain.comparison.ComparisonResult -import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableEncrypted @@ -29,7 +28,8 @@ import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.model.ExternalCredentialParams -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.fetchsubject.FetchSubjectParams import com.simprints.feature.fetchsubject.FetchSubjectResult import com.simprints.feature.login.LoginError @@ -303,7 +303,7 @@ class OrchestratorCacheIntegrationTest { ), EnrolLastBiometricStepResult.EnrolLastBiometricsResult("subjectId"), ), - scannedCredential = null, + credentialSearchResult = null, ), status = StepStatus.COMPLETED, result = ValidateSubjectPoolResult(true), @@ -314,7 +314,7 @@ class OrchestratorCacheIntegrationTest { destinationId = 6, params = SelectSubjectParams("projectId", "subjectId", null), status = StepStatus.COMPLETED, - result = SelectSubjectResult(true, savedCredential = null), + result = SelectSubjectResult(true, credentialSearchResult = null), ), Step( id = StepId.VALIDATE_ID_POOL, @@ -367,19 +367,18 @@ class OrchestratorCacheIntegrationTest { ), ), status = StepStatus.COMPLETED, - result = ExternalCredentialSearchResult( + result = ExternalCredentialSearchResult.Complete( flowType = FlowType.IDENTIFY, - scannedCredential = ScannedCredential( + scannedCredentialResult = ScannedCredentialResult( credentialScanId = "scanId", - credential = "credential".asTokenizableEncrypted(), - credentialType = ExternalCredentialType.GhanaIdCard, + document = MfidDocument.GhanaIdCard(credential = "credential".asTokenizableRaw()), documentImagePath = "image/path.jpg", zoomedCredentialImagePath = "image/path.jpg", credentialBoundingBox = BoundingBox(0, 1, 2, 3), scanStartTime = Timestamp(1L), - scanEndTime = Timestamp(2L, false, 123L), - scannedValue = "credential".asTokenizableRaw(), + scanEndTime = Timestamp(2L), ), + confirmedCredential = "credential".asTokenizableRaw(), matchResults = listOf( CredentialMatch( credential = "credential".asTokenizableEncrypted(), diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCaseTest.kt index c46a13f783..7eef3b5011 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/MapStepsForLastBiometricEnrolUseCaseTest.kt @@ -142,7 +142,7 @@ internal class MapStepsForLastBiometricEnrolUseCaseTest { } val result = useCase( listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns listOf(match) }, ), @@ -160,7 +160,7 @@ internal class MapStepsForLastBiometricEnrolUseCaseTest { fun `maps ExternalCredentialSearchResult with empty matches to null (filtered out)`() { val result = useCase( listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns emptyList() }, ), @@ -183,7 +183,7 @@ internal class MapStepsForLastBiometricEnrolUseCaseTest { } val result = useCase( listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns listOf(match1, match2) }, ), diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapperTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapperTest.kt new file mode 100644 index 0000000000..5abdfc97e6 --- /dev/null +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppExternalCredentialMapperTest.kt @@ -0,0 +1,265 @@ +package com.simprints.feature.orchestrator.usecases.response + +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult +import org.junit.Test + +internal class AppExternalCredentialMapperTest { + @Test + fun `returns null when search result is null`() { + val result = (null as ExternalCredentialSearchResult.Complete?).toAppExternalCredential() + + assertThat(result).isNull() + } + + @Test + fun `maps scan id as credential id`() { + val scanId = "test-scan-id" + val searchResult = makeSearchResult( + document = nhisCard(), + credentialScanId = scanId, + ) + + val result = searchResult.toAppExternalCredential() + + assertThat(result?.id).isEqualTo(scanId) + } + + @Test + fun `maps confirmed credential as value`() { + val confirmedCredential = "12345678".asTokenizableRaw() + val searchResult = makeSearchResult( + document = nhisCard(), + confirmedCredential = confirmedCredential, + ) + + val result = searchResult.toAppExternalCredential() + + assertThat(result?.value).isEqualTo(confirmedCredential) + } + + @Test + fun `maps nhis card credential type`() { + val searchResult = makeSearchResult(document = nhisCard()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.type).isEqualTo(ExternalCredentialType.NHISCard) + } + + @Test + fun `maps ghana id card credential type`() { + val searchResult = makeSearchResult(document = ghanaIdCard()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.type).isEqualTo(ExternalCredentialType.GhanaIdCard) + } + + @Test + fun `maps qr code credential type`() { + val searchResult = makeSearchResult(document = qrCode()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.type).isEqualTo(ExternalCredentialType.QRCode) + } + + @Test + fun `maps nhis card name field`() { + val searchResult = makeSearchResult(document = nhisCard(name = "JOHN DOE")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("name", "JOHN DOE") + } + + @Test + fun `maps nhis card dateOfBirth field`() { + val searchResult = makeSearchResult(document = nhisCard(dateOfBirth = "01/01/1990")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("dateOfBirth", "01/01/1990") + } + + @Test + fun `maps nhis card sex field`() { + val searchResult = makeSearchResult(document = nhisCard(sex = "M")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("sex", "M") + } + + @Test + fun `maps nhis card dateOfIssue field`() { + val searchResult = makeSearchResult(document = nhisCard(dateOfIssue = "01/01/2020")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("dateOfIssue", "01/01/2020") + } + + @Test + fun `omits null nhis card fields from non-credential fields`() { + val searchResult = makeSearchResult(document = nhisCard()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).isEmpty() + } + + @Test + fun `maps all present nhis card fields`() { + val searchResult = makeSearchResult( + document = nhisCard( + name = "JOHN DOE", + dateOfBirth = "01/01/1990", + sex = "M", + dateOfIssue = "01/01/2020", + ), + ) + + val result = searchResult.toAppExternalCredential() + + assertThat(result?.nonCredentialFields).containsExactly( + "name", + "JOHN DOE", + "dateOfBirth", + "01/01/1990", + "sex", + "M", + "dateOfIssue", + "01/01/2020", + ) + } + + @Test + fun `maps ghana id card surname field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(surname = "DOE")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("surname", "DOE") + } + + @Test + fun `maps ghana id card firstName field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(firstName = "JOHN")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("firstName", "JOHN") + } + + @Test + fun `maps ghana id card nationality field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(nationality = "GHANAIAN")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("nationality", "GHANAIAN") + } + + @Test + fun `maps ghana id card dateOfBirth field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(dateOfBirth = "01/01/1990")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("dateOfBirth", "01/01/1990") + } + + @Test + fun `maps ghana id card height field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(height = "1.75")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("height", "1.75") + } + + @Test + fun `maps ghana id card documentNumber field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(documentNumber = "123456")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("documentNumber", "123456") + } + + @Test + fun `maps ghana id card placeOfIssue field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(placeOfIssue = "ACCRA")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("placeOfIssue", "ACCRA") + } + + @Test + fun `maps ghana id card dateOfIssue field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(dateOfIssue = "01/01/2020")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("dateOfIssue", "01/01/2020") + } + + @Test + fun `maps ghana id card dateOfExpiry field`() { + val searchResult = makeSearchResult(document = ghanaIdCard(dateOfExpiry = "01/01/2030")) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).containsEntry("dateOfExpiry", "01/01/2030") + } + + @Test + fun `omits null ghana id card fields from non-credential fields`() { + val searchResult = makeSearchResult(document = ghanaIdCard()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).isEmpty() + } + + @Test + fun `qr code has empty non-credential fields`() { + val searchResult = makeSearchResult(document = qrCode()) + val result = searchResult.toAppExternalCredential() + assertThat(result?.nonCredentialFields).isEmpty() + } + + private fun makeSearchResult( + document: MfidDocument, + credentialScanId: String = "scan-id", + confirmedCredential: com.simprints.core.domain.tokenization.TokenizableString.Raw = "12345678".asTokenizableRaw(), + ) = ExternalCredentialSearchResult.Complete( + flowType = FlowType.ENROL, + scannedCredentialResult = ScannedCredentialResult( + credentialScanId = credentialScanId, + document = document, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = Timestamp(0L), + scanEndTime = Timestamp(1L), + ), + confirmedCredential = confirmedCredential, + matchResults = emptyList(), + ) + + private fun nhisCard( + credential: String = "12345678", + name: String? = null, + dateOfBirth: String? = null, + sex: String? = null, + dateOfIssue: String? = null, + ) = MfidDocument.GhanaNhisCard( + credential = credential.asTokenizableRaw(), + name = name?.asTokenizableRaw(), + dateOfBirth = dateOfBirth?.asTokenizableRaw(), + sex = sex?.asTokenizableRaw(), + dateOfIssue = dateOfIssue?.asTokenizableRaw(), + ) + + private fun ghanaIdCard( + credential: String = "GHA-123456789-0", + surname: String? = null, + firstName: String? = null, + nationality: String? = null, + dateOfBirth: String? = null, + height: String? = null, + documentNumber: String? = null, + placeOfIssue: String? = null, + dateOfIssue: String? = null, + dateOfExpiry: String? = null, + ) = MfidDocument.GhanaIdCard( + credential = credential.asTokenizableRaw(), + surname = surname?.asTokenizableRaw(), + firstName = firstName?.asTokenizableRaw(), + nationality = nationality?.asTokenizableRaw(), + dateOfBirth = dateOfBirth?.asTokenizableRaw(), + height = height?.asTokenizableRaw(), + documentNumber = documentNumber?.asTokenizableRaw(), + placeOfIssue = placeOfIssue?.asTokenizableRaw(), + dateOfIssue = dateOfIssue?.asTokenizableRaw(), + dateOfExpiry = dateOfExpiry?.asTokenizableRaw(), + ) + + private fun qrCode(credential: String = "qr-code-value") = MfidDocument.GhanaQrCode( + credential = credential.asTokenizableRaw(), + ) +} diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCaseTest.kt index 1db75a3f40..0ce5c5f2e2 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateConfirmIdentityResponseUseCaseTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.simprints.feature.selectsubject.SelectSubjectResult import com.simprints.infra.orchestration.data.responses.AppConfirmationResponse import com.simprints.infra.orchestration.data.responses.AppErrorResponse +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import io.mockk.mockk import org.junit.Before import org.junit.Test @@ -21,7 +22,7 @@ class CreateConfirmIdentityResponseUseCaseTest { assertThat( useCase( listOf( - SelectSubjectResult(true, mockk()), + SelectSubjectResult(true, mockk(relaxed = true)), mockk(), ), ), diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt index 27f2d72b92..096690f4a5 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt @@ -1,13 +1,14 @@ package com.simprints.feature.orchestrator.usecases.response import com.google.common.truth.Truth.* +import com.simprints.core.domain.capture.BiometricReferenceCapture import com.simprints.core.domain.common.Modality +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.core.domain.capture.BiometricReferenceCapture import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.feature.externalcredential.ExternalCredentialMapper import com.simprints.feature.externalcredential.ExternalCredentialSearchResult -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.orchestrator.exceptions.MissingCaptureException import com.simprints.infra.config.store.models.Project import com.simprints.infra.eventsync.sync.common.EnrolmentRecordFactory @@ -27,6 +28,9 @@ internal class CreateEnrolResponseUseCaseTest { @MockK lateinit var enrolRecord: EnrolRecordUseCase + @MockK + lateinit var credentialMapper: ExternalCredentialMapper + @MockK lateinit var project: Project @@ -48,7 +52,7 @@ internal class CreateEnrolResponseUseCaseTest { coJustRun { enrolRecord.invoke(any(), any()) } - useCase = CreateEnrolResponseUseCase(enrolmentRecordFactory, enrolRecord) + useCase = CreateEnrolResponseUseCase(enrolmentRecordFactory, enrolRecord, credentialMapper) } @Test @@ -97,14 +101,14 @@ internal class CreateEnrolResponseUseCaseTest { @Test fun `correctly processes external credential result`() = runTest { val externalCredentialType = ExternalCredentialType.GhanaIdCard - val scannedCredentialMock = mockk { - every { credentialScanId } returns "scanId" - every { credential } returns credentialEncrypted - every { credentialType } returns externalCredentialType - } - val credentialSearchResult = mockk { - every { scannedCredential } returns scannedCredentialMock - } + val credentialSearchResult = mockk(relaxed = true) + val mappedCredential = ExternalCredential( + id = "scanId", + value = credentialEncrypted, + subjectId = enrolmentSubjectId, + type = externalCredentialType, + ) + coEvery { credentialMapper.mapExternalCredential(credentialSearchResult, enrolmentSubjectId) } returns mappedCredential every { enrolmentRecordFactory.buildFromCaptureResults( diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt index ed7dcf2465..1aba2f54ad 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt @@ -10,7 +10,6 @@ import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.infra.config.store.models.DecisionPolicy import com.simprints.infra.config.store.models.ModalitySdkType import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.matching.MatchResult import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse @@ -25,9 +24,6 @@ class CreateIdentifyResponseUseCaseTest { @MockK lateinit var eventRepository: SessionEventRepository - @MockK - lateinit var tokenizationProcessor: TokenizationProcessor - @MockK lateinit var project: Project @@ -39,7 +35,7 @@ class CreateIdentifyResponseUseCaseTest { coEvery { eventRepository.getCurrentSessionScope().id } returns "sessionId" - useCase = CreateIdentifyResponseUseCase(eventRepository, tokenizationProcessor) + useCase = CreateIdentifyResponseUseCase(eventRepository) } @Test @@ -262,9 +258,8 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.verificationMatchThreshold } returns 0.0f }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns faceMatches + fingerprintMatches - every { scannedCredential } returns null }, ), project = project, @@ -317,9 +312,8 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.verificationMatchThreshold } returns 0.0f }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns fingerprintMatches + faceMatches - every { scannedCredential } returns null }, ), project = project, @@ -355,9 +349,8 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns credentialFaceMatches - every { scannedCredential } returns null }, MatchResult( listOf(ComparisonResult(subjectId = sharedGuid, comparisonScore = faceConfidence)), @@ -398,9 +391,8 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.verificationMatchThreshold } returns 0.0f }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns credentialFingerprintMatches - every { scannedCredential } returns null }, MatchResult( listOf(ComparisonResult(subjectId = sharedGuid, comparisonScore = fingerprintConfidence)), @@ -470,9 +462,8 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns credentialFaceMatches - every { scannedCredential } returns null }, faceMatchResults, ), @@ -495,12 +486,10 @@ class CreateIdentifyResponseUseCaseTest { } @Test - fun `Returns scanned credential when decryption succeeds`() = runTest { + fun `Returns scanned credential when credential search result is present`() = runTest { val id = "id" val type = ExternalCredentialType.NHISCard - val expectedDecrypted = "expectedDecrypted".asTokenizableRaw() - - every { tokenizationProcessor.decrypt(any(), any(), any()) } returns expectedDecrypted + val confirmedCredential = "expectedValue".asTokenizableRaw() val result = useCase( mockk { @@ -510,12 +499,12 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.getSdkConfiguration(any())?.decisionPolicy } returns null }, results = listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns emptyList() - every { scannedCredential } returns mockk { + every { this@mockk.confirmedCredential } returns confirmedCredential + every { scannedCredentialResult } returns mockk(relaxed = true) { every { credentialScanId } returns id every { credentialType } returns type - every { credential } returns mockk() } }, ), @@ -525,7 +514,7 @@ class CreateIdentifyResponseUseCaseTest { assertThat((result as AppIdentifyResponse).scannedCredential).isNotNull() assertThat(result.scannedCredential?.id).isEqualTo(id) assertThat(result.scannedCredential?.type).isEqualTo(type) - assertThat(result.scannedCredential?.value).isEqualTo(expectedDecrypted) + assertThat(result.scannedCredential?.value).isEqualTo(confirmedCredential) } private fun createFaceMatchResult(vararg confidences: Float): Serializable = MatchResult( diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCaseTest.kt index 201a72a655..50fa8f1ba0 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/IsNewEnrolmentUseCaseTest.kt @@ -190,7 +190,7 @@ internal class IsNewEnrolmentUseCaseTest { useCase( projectConfiguration, listOf( - mockk { + mockk(relaxed = true) { every { matchResults } returns credentialMatches }, ), diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCaseTest.kt index f090693ef3..cc790c6b77 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/steps/BuildStepsUseCaseTest.kt @@ -5,7 +5,7 @@ import com.simprints.core.domain.common.AgeGroup import com.simprints.core.domain.common.Modality import com.simprints.core.domain.common.TemplateIdentifier import com.simprints.core.domain.externalcredential.ExternalCredentialType -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.orchestrator.cache.OrchestratorCache import com.simprints.feature.orchestrator.exceptions.SubjectAgeNotSupportedException import com.simprints.feature.orchestrator.steps.Step @@ -46,7 +46,7 @@ class BuildStepsUseCaseTest { private lateinit var nec: FingerprintConfiguration.FingerprintSdkConfiguration @RelaxedMockK - private lateinit var cachedScannedCredential: ScannedCredential + private lateinit var cachedScannedCredentialResult: ExternalCredentialSearchResult.Complete private lateinit var useCase: BuildStepsUseCase private lateinit var enrolmentSubjectId: String @@ -115,7 +115,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -135,7 +135,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -154,7 +154,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -178,7 +178,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -198,7 +198,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -221,7 +221,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -241,7 +241,7 @@ class BuildStepsUseCaseTest { every { projectConfiguration.custom } returns mapOf("validateIdentificationPool" to JsonPrimitive(true)) - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -264,7 +264,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -288,7 +288,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -307,7 +307,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -326,7 +326,7 @@ class BuildStepsUseCaseTest { Step(StepId.FACE_CAPTURE, mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)), ) - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -345,7 +345,7 @@ class BuildStepsUseCaseTest { Step(StepId.FACE_CAPTURE, mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)), ) - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -366,7 +366,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -387,7 +387,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -408,7 +408,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -430,7 +430,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns 25 // Subject age within the supported range - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -454,7 +454,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns 25 // Subject age within the supported range - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -480,7 +480,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns 25 // Subject age within the supported range - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -507,7 +507,7 @@ class BuildStepsUseCaseTest { every { getSubjectAgeIfAvailable() } returns 25 every { biometricDataSource } returns "COMMCARE" } - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -533,7 +533,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns 25 // Subject age within the supported range - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -560,7 +560,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns 25 // Subject age within the supported range every { action.biometricDataSource } returns "COMMCARE" - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -588,7 +588,7 @@ class BuildStepsUseCaseTest { every { action.getSubjectAgeIfAvailable() } returns 20 // Subject age not supported by any SDK assertThrows(SubjectAgeNotSupportedException::class.java) { - runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) } + runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) } } } @@ -604,7 +604,7 @@ class BuildStepsUseCaseTest { every { action.getSubjectAgeIfAvailable() } returns 20 // Subject age not supported by any SDK assertThrows(SubjectAgeNotSupportedException::class.java) { - runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) } + runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) } } } @@ -620,7 +620,7 @@ class BuildStepsUseCaseTest { every { action.getSubjectAgeIfAvailable() } returns 20 // Subject age not supported by any SDK assertThrows(SubjectAgeNotSupportedException::class.java) { - runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) } + runBlocking { useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) } } } @@ -770,7 +770,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) // Should not contain EXTERNAL_CREDENTIAL step assertStepOrder( @@ -791,7 +791,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -812,7 +812,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) assertStepOrder( steps, @@ -836,7 +836,7 @@ class BuildStepsUseCaseTest { val action = mockk(relaxed = true) every { action.getSubjectAgeIfAvailable() } returns null - val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredential) + val steps = useCase.build(action, projectConfiguration, enrolmentSubjectId, cachedScannedCredentialResult) // Should not contain EXTERNAL_CREDENTIAL step for VERIFY flow assertStepOrder( @@ -858,7 +858,7 @@ class BuildStepsUseCaseTest { val testProjectId = "projectId" val guid = "guid" val projectConfiguration = mockCommonProjectConfiguration() - val cachedScannedCredential = mockk(relaxed = true) + val cachedCredentialSearchResult = mockk(relaxed = true) val action = mockk(relaxed = true) { every { projectId } returns testProjectId @@ -870,7 +870,7 @@ class BuildStepsUseCaseTest { action = action, projectConfiguration = projectConfiguration, enrolmentSubjectId = enrolmentSubjectId, - cachedScannedCredential = cachedScannedCredential, + cachedCredentialSearchResult = cachedCredentialSearchResult, ) assertStepOrder(steps, StepId.CONFIRM_IDENTITY) @@ -881,6 +881,6 @@ class BuildStepsUseCaseTest { assertThat(params).isNotNull() assertThat(params?.projectId).isEqualTo(testProjectId) assertThat(params?.subjectId).isEqualTo(guid) - assertThat(params?.scannedCredential).isEqualTo(cachedScannedCredential) + assertThat(params?.credentialSearchResult).isEqualTo(cachedCredentialSearchResult) } } diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectContract.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectContract.kt index d56ce2d789..d882bf127c 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectContract.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectContract.kt @@ -1,6 +1,6 @@ package com.simprints.feature.selectsubject -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult object SelectSubjectContract { val DESTINATION = R.id.selectSubjectFragment @@ -8,6 +8,6 @@ object SelectSubjectContract { fun getParams( projectId: String, subjectId: String, - scannedCredential: ScannedCredential? - ) = SelectSubjectParams(projectId, subjectId, scannedCredential) + scannedCredentialResult: ExternalCredentialSearchResult.Complete?, + ) = SelectSubjectParams(projectId, subjectId, scannedCredentialResult) } diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectParams.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectParams.kt index cde736a770..59c778594b 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectParams.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectParams.kt @@ -2,7 +2,7 @@ package com.simprints.feature.selectsubject import androidx.annotation.Keep import com.simprints.core.domain.step.StepParams -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -12,5 +12,5 @@ import kotlinx.serialization.Serializable data class SelectSubjectParams( val projectId: String, val subjectId: String, - val scannedCredential: ScannedCredential?, + val credentialSearchResult: ExternalCredentialSearchResult.Complete?, ) : StepParams diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectResult.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectResult.kt index 3138436ea5..aabd002e63 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectResult.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/SelectSubjectResult.kt @@ -1,8 +1,8 @@ package com.simprints.feature.selectsubject import androidx.annotation.Keep -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.step.StepResult +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,5 +11,5 @@ import kotlinx.serialization.Serializable @SerialName("SelectSubjectResult") data class SelectSubjectResult( val isSubjectIdSaved: Boolean, - val savedCredential: ExternalCredential? = null, + val credentialSearchResult: ExternalCredentialSearchResult.Complete? = null, ) : StepResult diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/model/SelectSubjectState.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/model/SelectSubjectState.kt index d2c3f93dc0..6b2d768a5d 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/model/SelectSubjectState.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/model/SelectSubjectState.kt @@ -3,7 +3,7 @@ package com.simprints.feature.selectsubject.model import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult @Keep @ExcludedFromGeneratedTestCoverageReports("Data struct") @@ -13,7 +13,7 @@ internal sealed class SelectSubjectState { data object SavingExternalCredential : SelectSubjectState() data class CredentialDialogDisplayed( - val scannedCredential: ScannedCredential, + val scannedCredentialResult: ScannedCredentialResult, val displayedCredential: TokenizableString.Raw, ) : SelectSubjectState() diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectFragment.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectFragment.kt index c4a7e694dd..254413684b 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectFragment.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectFragment.kt @@ -14,7 +14,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetDialog import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.view.ScannedCredentialDialog import com.simprints.feature.selectsubject.R import com.simprints.feature.selectsubject.SelectSubjectParams @@ -83,7 +83,7 @@ internal class SelectSubjectFragment : Fragment(R.layout.fragment_select_subject viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> when (state) { is SelectSubjectState.CredentialDialogDisplayed -> { - displayCredentialDialog(state.scannedCredential, state.displayedCredential) + displayCredentialDialog(state.scannedCredentialResult, state.displayedCredential) } SelectSubjectState.SavingExternalCredential -> renderSavingCredential() @@ -106,13 +106,16 @@ internal class SelectSubjectFragment : Fragment(R.layout.fragment_select_subject confirmationSentContainer.isVisible = true } - private fun displayCredentialDialog(scannedCredential: ScannedCredential, displayedCredential: TokenizableString.Raw) { + private fun displayCredentialDialog( + scannedCredentialResult: ScannedCredentialResult, + displayedCredential: TokenizableString.Raw, + ) { dialog = ScannedCredentialDialog( context = requireActivity(), - credential = scannedCredential, + credential = scannedCredentialResult, displayedCredential = displayedCredential, - onConfirm = { viewModel.saveCredential(scannedCredential) }, - onSkip = (viewModel::finishWithoutSavingCredential) + onConfirm = { viewModel.saveCredential() }, + onSkip = (viewModel::finishWithoutSavingCredential), ).also(ScannedCredentialDialog::show) } diff --git a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt index c92edddcd6..fb8b9a4dae 100644 --- a/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt +++ b/feature/select-subject/src/main/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModel.kt @@ -10,8 +10,7 @@ import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.core.tools.extentions.isValidGuid import com.simprints.core.tools.time.TimeHelper -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential -import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.feature.selectsubject.SelectSubjectParams import com.simprints.feature.selectsubject.SelectSubjectResult @@ -69,16 +68,15 @@ internal class SelectSubjectViewModel @AssistedInject constructor( viewModelScope.launch { val isSaved = saveGuidSelection(projectId = params.projectId, subjectId = params.subjectId) if (!isSaved) { - _finish.send(SelectSubjectResult(isSubjectIdSaved = false, savedCredential = null)) + _finish.send(SelectSubjectResult(isSubjectIdSaved = false, credentialSearchResult = null)) return@launch } - val dialogDisplayedState = getDisplayDialogStateIfRequired(params.scannedCredential, params.subjectId) + val dialogDisplayedState = getDisplayDialogStateIfRequired(params.credentialSearchResult, params.subjectId) if (dialogDisplayedState != null) { updateState { dialogDisplayedState } } else { - val credential = params.scannedCredential?.toExternalCredential(params.subjectId) - _finish.send(SelectSubjectResult(isSubjectIdSaved = true, savedCredential = credential)) + _finish.send(SelectSubjectResult(isSubjectIdSaved = true, credentialSearchResult = params.credentialSearchResult)) } } } @@ -93,18 +91,24 @@ internal class SelectSubjectViewModel @AssistedInject constructor( } private suspend fun getDisplayDialogStateIfRequired( - scannedCredential: ScannedCredential?, + credentialSearchResult: ExternalCredentialSearchResult.Complete?, subjectId: String, ): SelectSubjectState.CredentialDialogDisplayed? { - if (scannedCredential == null) return null - val credential = scannedCredential.credential + if (credentialSearchResult == null) return null val project = configRepository.getProject() ?: return null + val scannedCredentialResult = credentialSearchResult.scannedCredentialResult + val credential = credentialSearchResult.confirmedCredential + val encryptedCredential = tokenizationProcessor.encrypt( + decrypted = credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as TokenizableString.Tokenized val alreadyLinkedSubject = enrolmentRecordRepository .load( EnrolmentRecordQuery( projectId = project.id, subjectId = subjectId, - externalCredential = credential, + externalCredential = encryptedCredential, ), ).firstOrNull() @@ -112,7 +116,7 @@ internal class SelectSubjectViewModel @AssistedInject constructor( // Confirmation of "none_selected" (or any non UUID value) should not display the dialog, // but still remove update event from session and reset previously linked external credentials resetExternalCredentialsUseCase( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = params.subjectId, ) return null @@ -121,20 +125,19 @@ internal class SelectSubjectViewModel @AssistedInject constructor( // Credentials already linked to the correct subject, so no need to re-link if (alreadyLinkedSubject != null && alreadyLinkedSubject.subjectId == subjectId) return null - val decrypted = tokenizationProcessor.decrypt( - encrypted = credential, - tokenKeyType = TokenKeyType.ExternalCredential, - project = project, - ) as TokenizableString.Raw - return SelectSubjectState.CredentialDialogDisplayed(scannedCredential = scannedCredential, displayedCredential = decrypted) + return SelectSubjectState.CredentialDialogDisplayed( + scannedCredentialResult = scannedCredentialResult, + displayedCredential = credential, + ) } - fun saveCredential(scannedCredential: ScannedCredential) { + fun saveCredential() { updateState { SelectSubjectState.SavingExternalCredential } viewModelScope.launch { - val addedCredential = try { + val credentialSearchResult = params.credentialSearchResult + try { resetExternalCredentialsUseCase( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = params.subjectId, ) @@ -142,22 +145,20 @@ internal class SelectSubjectViewModel @AssistedInject constructor( if (params.subjectId.isValidGuid()) { saveCredentialSelectionEvent(params.subjectId) } - scannedCredential } catch (e: Exception) { Simber.e("Failed to attach scanned credential", e, tag = SESSION) - null } _finish.send( SelectSubjectResult( isSubjectIdSaved = true, - savedCredential = addedCredential?.toExternalCredential(params.subjectId), + credentialSearchResult = credentialSearchResult, ), ) } } fun finishWithoutSavingCredential() { - _finish.send(SelectSubjectResult(isSubjectIdSaved = true, savedCredential = null)) + _finish.send(SelectSubjectResult(isSubjectIdSaved = true, credentialSearchResult = null)) } private suspend fun saveCredentialSelectionEvent(subjectId: String) = with(sessionCoroutineScope) { diff --git a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt index 2b64bc2e63..73d5edcfd5 100644 --- a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt +++ b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt @@ -3,12 +3,14 @@ package com.simprints.feature.selectsubject.screen import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.* import com.jraska.livedata.test -import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.screens.search.model.MfidDocument +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.usecase.ResetExternalCredentialsInSessionUseCase import com.simprints.feature.selectsubject.SelectSubjectParams import com.simprints.feature.selectsubject.model.SelectSubjectState @@ -110,7 +112,7 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } @Test @@ -125,7 +127,7 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isFalse() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } @Test @@ -141,81 +143,94 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isFalse() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } @Test fun `displays credential dialog when credential is scanned and not already linked`() = runTest { - val scannedCredential = mockk(relaxed = true) - val displayedCredential = mockk(relaxed = true) - setupCredentialState(displayedCredential, repositoryResponse = emptyList()) + val confirmedCredential = mockk(relaxed = true) + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential + } + setupCredentialState(confirmedCredential, repositoryResponse = emptyList()) - val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) + val viewModel = createViewModel(params = selectSubjectParams.copy(credentialSearchResult = credentialSearchResult)) val state = viewModel.stateLiveData.test().value() assertThat(state).isInstanceOf(SelectSubjectState.CredentialDialogDisplayed::class.java) val dialogState = state as SelectSubjectState.CredentialDialogDisplayed - assertThat(dialogState.scannedCredential).isEqualTo(scannedCredential) - assertThat(dialogState.displayedCredential).isEqualTo(displayedCredential) + assertThat(dialogState.scannedCredentialResult).isEqualTo(scannedCredentialResult) + assertThat(dialogState.displayedCredential).isEqualTo(confirmedCredential) } @Test fun `displays credential dialog when credential is scanned and linked to different subject`() = runTest { - val scannedCredential = mockk(relaxed = true) - val displayedCredential = mockk(relaxed = true) + val confirmedCredential = mockk(relaxed = true) + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential + } val repositoryResponse = listOf(mockk { every { subjectId } returns "not_this_subject_id" }) - setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) + setupCredentialState(confirmedCredential, repositoryResponse = repositoryResponse) - val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) + val viewModel = createViewModel(params = selectSubjectParams.copy(credentialSearchResult = credentialSearchResult)) val state = viewModel.stateLiveData.test().value() assertThat(state).isInstanceOf(SelectSubjectState.CredentialDialogDisplayed::class.java) val dialogState = state as SelectSubjectState.CredentialDialogDisplayed - assertThat(dialogState.scannedCredential).isEqualTo(scannedCredential) - assertThat(dialogState.displayedCredential).isEqualTo(displayedCredential) + assertThat(dialogState.scannedCredentialResult).isEqualTo(scannedCredentialResult) + assertThat(dialogState.displayedCredential).isEqualTo(confirmedCredential) } @Test fun `does not display credential dialog when credential is already linked to same subject`() = runTest { - val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val type = ExternalCredentialType.NHISCard - val scannedCredential = mockk { - every { credentialScanId } returns "credentialId" - every { credential } returns tokenizedValue - every { credentialType } returns type + val confirmedCredential = "12345678".asTokenizableRaw() + val scannedCredentialResult = ScannedCredentialResult( + credentialScanId = "credentialId", + document = MfidDocument.GhanaNhisCard( + credential = confirmedCredential, + ), + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = Timestamp(0L), + scanEndTime = Timestamp(1L), + ) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential } - val displayedCredential = mockk(relaxed = true) val repositoryResponse = listOf(mockk { every { subjectId } returns SUBJECT_ID }) - setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) + setupCredentialState(confirmedCredential, repositoryResponse = repositoryResponse) - val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) + val viewModel = createViewModel(params = selectSubjectParams.copy(credentialSearchResult = credentialSearchResult)) val result = viewModel.finish .test() .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential?.value).isEqualTo(tokenizedValue) - assertThat(result?.savedCredential?.type).isEqualTo(type) + assertThat(result?.credentialSearchResult).isEqualTo(credentialSearchResult) } @Test fun `does not display credential dialog when subject ID is none_selected`() = runTest { - val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val type = ExternalCredentialType.NHISCard - val scannedCredential = mockk { - every { credentialScanId } returns "credentialId" - every { credential } returns tokenizedValue - every { credentialType } returns type + val confirmedCredential = "12345678".asTokenizableRaw() + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential } - val displayedCredential = mockk(relaxed = true) val repositoryResponse = listOf(mockk { every { subjectId } returns SUBJECT_ID }) - setupCredentialState(displayedCredential, repositoryResponse = repositoryResponse) + setupCredentialState(confirmedCredential, repositoryResponse = repositoryResponse) val viewModel = createViewModel( params = selectSubjectParams.copy( subjectId = "none_selected", - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ), ) @@ -227,19 +242,23 @@ internal class SelectSubjectViewModelTest { } @Test - fun `does not display credential dialog when project not availalbe`() = runTest { - val scannedCredential = mockk(relaxed = true) - val displayedCredential = mockk(relaxed = true) + fun `does not display credential dialog when project not available`() = runTest { + val confirmedCredential = mockk(relaxed = true) + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential + } val repositoryResponse = listOf(mockk { every { subjectId } returns "not_this_subject_id" }) setupCredentialState( - displayedCredential, + confirmedCredential, repositoryResponse = repositoryResponse, configuredProject = null, ) val viewModel = createViewModel( params = selectSubjectParams.copy( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, ), ) @@ -261,11 +280,11 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } private fun setupCredentialState( - displayedCredential: TokenizableString.Raw, + confirmedCredential: TokenizableString.Raw, repositoryResponse: List, configuredProject: Project? = project, ) { @@ -275,22 +294,21 @@ internal class SelectSubjectViewModelTest { coEvery { configRepository.getProject() } returns configuredProject coEvery { enrolmentRecordRepository.load(any()) } returns repositoryResponse coEvery { - tokenizationProcessor.decrypt( - encrypted = any(), + tokenizationProcessor.encrypt( + decrypted = confirmedCredential, tokenKeyType = TokenKeyType.ExternalCredential, project = any(), ) - } returns displayedCredential + } returns "encrypted_credential".asTokenizableEncrypted() } @Test fun `saveCredential successfully saves credential`() = runTest { - val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val type = ExternalCredentialType.NHISCard - val scannedCredential = mockk { - every { credentialScanId } returns "credentialId" - every { credential } returns tokenizedValue - every { credentialType } returns type + val confirmedCredential = "12345678".asTokenizableRaw() + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential } coEvery { eventRepository.getEventsInCurrentSession() @@ -304,8 +322,8 @@ internal class SelectSubjectViewModelTest { resetScannedCredentialsInSession(any(), any()) } - val viewModel = createViewModel(params = selectSubjectParams.copy(scannedCredential = scannedCredential)) - viewModel.saveCredential(scannedCredential) + val viewModel = createViewModel(params = selectSubjectParams.copy(credentialSearchResult = credentialSearchResult)) + viewModel.saveCredential() val state = viewModel.stateLiveData.test().value() assertThat(state).isEqualTo(SelectSubjectState.SavingExternalCredential) @@ -315,12 +333,11 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential?.value).isEqualTo(tokenizedValue) - assertThat(result?.savedCredential?.type).isEqualTo(type) + assertThat(result?.credentialSearchResult).isEqualTo(credentialSearchResult) coVerify { resetScannedCredentialsInSession( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = SUBJECT_ID, ) } @@ -328,13 +345,12 @@ internal class SelectSubjectViewModelTest { } @Test - fun `saveCredential does not saves update event if invalid subject id`() = runTest { - val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val type = ExternalCredentialType.NHISCard - val scannedCredential = mockk { - every { credentialScanId } returns "credentialId" - every { credential } returns tokenizedValue - every { credentialType } returns type + fun `saveCredential does not save update event if invalid subject id`() = runTest { + val confirmedCredential = "12345678".asTokenizableRaw() + val scannedCredentialResult = mockk(relaxed = true) + val credentialSearchResult = mockk(relaxed = true) { + every { this@mockk.scannedCredentialResult } returns scannedCredentialResult + every { this@mockk.confirmedCredential } returns confirmedCredential } coJustRun { @@ -342,9 +358,9 @@ internal class SelectSubjectViewModelTest { } val viewModel = createViewModel( - params = selectSubjectParams.copy(subjectId = "none_selected", scannedCredential = scannedCredential), + params = selectSubjectParams.copy(subjectId = "none_selected", credentialSearchResult = credentialSearchResult), ) - viewModel.saveCredential(scannedCredential) + viewModel.saveCredential() val state = viewModel.stateLiveData.test().value() assertThat(state).isEqualTo(SelectSubjectState.SavingExternalCredential) @@ -354,13 +370,12 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential?.value).isEqualTo(tokenizedValue) - assertThat(result?.savedCredential?.type).isEqualTo(type) + assertThat(result?.credentialSearchResult).isEqualTo(credentialSearchResult) coVerify { // Still needs to remove previous links resetScannedCredentialsInSession( - scannedCredential = scannedCredential, + credentialSearchResult = credentialSearchResult, subjectId = "none_selected", ) } @@ -369,15 +384,14 @@ internal class SelectSubjectViewModelTest { @Test fun `saveCredential handles exception when saving fails`() = runTest { - val scannedCredential = mockk(relaxed = true) coEvery { resetScannedCredentialsInSession( - scannedCredential = scannedCredential, + credentialSearchResult = any(), subjectId = SUBJECT_ID, ) } throws RuntimeException("RuntimeException") - viewModel.saveCredential(scannedCredential) + viewModel.saveCredential() val state = viewModel.stateLiveData.test().value() assertThat(state).isEqualTo(SelectSubjectState.SavingExternalCredential) @@ -387,7 +401,7 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } @Test @@ -399,7 +413,7 @@ internal class SelectSubjectViewModelTest { .value() .getContentIfNotHandled() assertThat(result?.isSubjectIdSaved).isTrue() - assertThat(result?.savedCredential).isNull() + assertThat(result?.credentialSearchResult).isNull() } companion object { diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/MutliFactorIdConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/MutliFactorIdConfiguration.kt index 100fa4afe0..4a1443e2d6 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/MutliFactorIdConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/models/MutliFactorIdConfiguration.kt @@ -1,12 +1,43 @@ package com.simprints.infra.config.store.local.models +import com.simprints.infra.config.store.models.GhanaIdCardConfig import com.simprints.infra.config.store.models.MultiFactorIdConfiguration +import com.simprints.infra.config.store.models.NhisCardConfig +import com.simprints.infra.config.store.models.QrCodeConfig internal fun MultiFactorIdConfiguration.toProto(): ProtoMultiFactorIdConfiguration = ProtoMultiFactorIdConfiguration .newBuilder() .addAllAllowedExternalCredentials(allowedExternalCredentials.map { it.toProto() }) + .also { if (ghanaIdCardConfig != null) it.setGhanaIdCardConfig(ghanaIdCardConfig.toProto()) } + .also { if (nhisCardConfig != null) it.setNhisCardConfig(nhisCardConfig.toProto()) } + .also { if (qrCodeConfig != null) it.setQrCodeConfig(qrCodeConfig.toProto()) } + .build() + +internal fun GhanaIdCardConfig.toProto(): ProtoGhanaIdCardConfig = ProtoGhanaIdCardConfig + .newBuilder() + .setIsCapturingAllFields(isCapturingAllFields) + .build() + +internal fun NhisCardConfig.toProto(): ProtoNhisCardConfig = ProtoNhisCardConfig + .newBuilder() + .setIsCapturingAllFields(isCapturingAllFields) + .build() + +internal fun QrCodeConfig.toProto(): ProtoQrCodeConfig = ProtoQrCodeConfig + .newBuilder() .build() internal fun ProtoMultiFactorIdConfiguration.toDomain(): MultiFactorIdConfiguration = MultiFactorIdConfiguration( - allowedExternalCredentials = allowedExternalCredentialsList.map { it.toDomain() } + allowedExternalCredentials = allowedExternalCredentialsList.map { it.toDomain() }, + ghanaIdCardConfig = if (hasGhanaIdCardConfig()) ghanaIdCardConfig.toDomain() else null, + nhisCardConfig = if (hasNhisCardConfig()) nhisCardConfig.toDomain() else null, + qrCodeConfig = if (hasQrCodeConfig()) QrCodeConfig else null, +) + +internal fun ProtoGhanaIdCardConfig.toDomain() = GhanaIdCardConfig( + isCapturingAllFields = isCapturingAllFields, +) + +internal fun ProtoNhisCardConfig.toDomain() = NhisCardConfig( + isCapturingAllFields = isCapturingAllFields, ) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/MultiFactorIdConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/MultiFactorIdConfiguration.kt index f315afa5f9..eb1fa31c65 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/MultiFactorIdConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/MultiFactorIdConfiguration.kt @@ -3,5 +3,18 @@ package com.simprints.infra.config.store.models import com.simprints.core.domain.externalcredential.ExternalCredentialType data class MultiFactorIdConfiguration( - val allowedExternalCredentials: List + val allowedExternalCredentials: List, + val ghanaIdCardConfig: GhanaIdCardConfig?, + val nhisCardConfig: NhisCardConfig?, + val qrCodeConfig: QrCodeConfig?, ) + +data class GhanaIdCardConfig( + val isCapturingAllFields: Boolean, +) + +data class NhisCardConfig( + val isCapturingAllFields: Boolean, +) + +object QrCodeConfig diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt index 4e2c5e9368..bd1f2bdc5e 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt @@ -2,7 +2,10 @@ package com.simprints.infra.config.store.remote.models import androidx.annotation.Keep import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.infra.config.store.models.GhanaIdCardConfig import com.simprints.infra.config.store.models.MultiFactorIdConfiguration +import com.simprints.infra.config.store.models.NhisCardConfig +import com.simprints.infra.config.store.models.QrCodeConfig import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.GHANA_CARD import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.NHIS_CARD import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.QR_CODE @@ -12,12 +15,40 @@ import kotlinx.serialization.Serializable @Serializable internal data class ApiMultiFactorIdConfiguration( val allowedExternalCredentials: List, + val ghanaCard: ApiGhanaIdCardConfig? = null, + val nhisCard: ApiNhisCardConfig? = null, + val qrCode: ApiQrCodeConfig? = null, ) { fun toDomain(): MultiFactorIdConfiguration = MultiFactorIdConfiguration( allowedExternalCredentials = allowedExternalCredentials.map { it.toDomain() }, + ghanaIdCardConfig = ghanaCard?.toDomain(), + nhisCardConfig = nhisCard?.toDomain(), + qrCodeConfig = qrCode?.toDomain(), ) } +@Keep +@Serializable +data class ApiGhanaIdCardConfig( + val isCapturingAllFields: Boolean, +) { + fun toDomain() = GhanaIdCardConfig(isCapturingAllFields = isCapturingAllFields) +} + +@Keep +@Serializable +data class ApiNhisCardConfig( + val isCapturingAllFields: Boolean, +) { + fun toDomain() = NhisCardConfig(isCapturingAllFields = isCapturingAllFields) +} + +@Keep +@Serializable +object ApiQrCodeConfig { + fun toDomain() = QrCodeConfig +} + @Keep enum class ApiExternalCredentialType { NHIS_CARD, diff --git a/infra/config-store/src/main/proto/project_config.proto b/infra/config-store/src/main/proto/project_config.proto index ea0199abb8..cf018c7198 100644 --- a/infra/config-store/src/main/proto/project_config.proto +++ b/infra/config-store/src/main/proto/project_config.proto @@ -26,6 +26,21 @@ enum ProtoExternalCredentialType { message ProtoMultiFactorIdConfiguration { repeated ProtoExternalCredentialType allowed_external_credentials = 1; + optional ProtoGhanaIdCardConfig ghana_id_card_config = 2; + optional ProtoNhisCardConfig nhis_card_config = 3; + optional ProtoQrCodeConfig qr_code_config = 4; +} + +message ProtoGhanaIdCardConfig { + bool is_capturing_all_fields = 1; +} + +message ProtoNhisCardConfig { + bool is_capturing_all_fields = 1; +} + +message ProtoQrCodeConfig { + // Empty, reserved for future configuration } message ProtoGeneralConfiguration { diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt index d58bc95edf..8b3b3b9feb 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt @@ -425,6 +425,9 @@ internal val allowedExternalCredential = ExternalCredentialType.NHISCard internal val multiFactorIdConfiguration = MultiFactorIdConfiguration( allowedExternalCredentials = listOf(allowedExternalCredential), + ghanaIdCardConfig = null, + nhisCardConfig = null, + qrCodeConfig = null, ) internal val protoMultiFactorIdConfiguration = ProtoMultiFactorIdConfiguration @@ -482,6 +485,9 @@ internal val apiAllowedExternalCredential = ApiExternalCredentialType.NHIS_CARD internal val apiMultiFactorIdConfiguration = ApiMultiFactorIdConfiguration( allowedExternalCredentials = listOf(apiAllowedExternalCredential), + ghanaCard = null, + nhisCard = null, + qrCode = null, ) internal val customKeyMap: Map = mapOf( diff --git a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppConfirmationResponse.kt b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppConfirmationResponse.kt index 19617e57cc..2cf7ec3ce5 100644 --- a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppConfirmationResponse.kt +++ b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppConfirmationResponse.kt @@ -2,7 +2,6 @@ package com.simprints.infra.orchestration.data.responses import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports -import com.simprints.core.domain.externalcredential.ExternalCredential import kotlinx.parcelize.Parcelize @Keep @@ -10,5 +9,5 @@ import kotlinx.parcelize.Parcelize @ExcludedFromGeneratedTestCoverageReports("Data struct") data class AppConfirmationResponse( val identificationOutcome: Boolean, - val externalCredential: ExternalCredential?, + val externalCredential: AppExternalCredential?, ) : AppResponse() diff --git a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppEnrolResponse.kt b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppEnrolResponse.kt index 66b04f4032..4a7f2f2b43 100644 --- a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppEnrolResponse.kt +++ b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppEnrolResponse.kt @@ -2,7 +2,6 @@ package com.simprints.infra.orchestration.data.responses import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports -import com.simprints.core.domain.externalcredential.ExternalCredential import kotlinx.parcelize.Parcelize @Keep @@ -10,5 +9,5 @@ import kotlinx.parcelize.Parcelize @ExcludedFromGeneratedTestCoverageReports("Data struct") data class AppEnrolResponse( val guid: String, - val externalCredential: ExternalCredential?, + val externalCredential: AppExternalCredential?, ) : AppResponse() diff --git a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppExternalCredential.kt b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppExternalCredential.kt index 532a536dd4..4bb215226e 100644 --- a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppExternalCredential.kt +++ b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppExternalCredential.kt @@ -12,4 +12,5 @@ data class AppExternalCredential( val id: String, val value: TokenizableString.Raw, val type: ExternalCredentialType, + val nonCredentialFields: Map, ) : Serializable diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt index 2801786747..d517b76ce0 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/testtools/Models.kt @@ -122,6 +122,9 @@ internal val allowedExternalCredential = ExternalCredentialType.NHISCard internal val multiFactorIdConfiguration = MultiFactorIdConfiguration( allowedExternalCredentials = listOf(allowedExternalCredential), + ghanaIdCardConfig = null, + nhisCardConfig = null, + qrCodeConfig = null, ) internal val synchronizationConfiguration = SynchronizationConfiguration(