diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c2c32e463e..73ff2ec7d4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -36,6 +36,7 @@ jobs: infra:recent-user-activity infra:config-store infra:config-sync + infra:credential-store infra:sync reportsId: infra1 @@ -51,6 +52,7 @@ jobs: infra:images infra:auth-store infra:auth-logic + infra:matching reportsId: infra2 feature-unit-tests1: @@ -65,6 +67,7 @@ jobs: feature:alert feature:exit-form feature:select-subject-age-group + feature:external-credential reportsId: feature1 feature-unit-tests2: 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 c829d71410..63f2395485 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,6 +5,8 @@ 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 @@ -21,6 +23,9 @@ import com.simprints.feature.clientapi.usecases.GetEnrolmentCreationEventForSubj import com.simprints.feature.clientapi.usecases.IsFlowCompletedWithErrorUseCase import com.simprints.feature.clientapi.usecases.SimpleEventReporter import com.simprints.infra.authstore.AuthStore +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.config.sync.ConfigManager import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber @@ -30,6 +35,7 @@ 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 @@ -53,6 +59,7 @@ class ClientApiViewModel @Inject internal constructor( private val configManager: ConfigManager, private val timeHelper: TimeHelper, private val persistentLogger: PersistentLogger, + private val tokenizationProcessor: TokenizationProcessor, ) : ViewModel() { val returnResponse: LiveData> get() = _returnResponse @@ -115,6 +122,7 @@ class ClientApiViewModel @Inject internal constructor( sessionId = currentSessionId, enrolledGuid = enrolResponse.guid, subjectActions = coSyncEnrolmentRecords, + externalCredential = enrolResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), ), ), ) @@ -139,6 +147,7 @@ class ClientApiViewModel @Inject internal constructor( actionIdentifier = action.actionIdentifier, sessionId = currentSessionId, identifications = identifyResponse.identifications, + isMultiFactorIdEnabled = identifyResponse.isMultiFactorIdEnabled, ), ), ) @@ -161,6 +170,7 @@ class ClientApiViewModel @Inject internal constructor( actionIdentifier = action.actionIdentifier, sessionId = currentSessionId, confirmed = confirmResponse.identificationOutcome, + externalCredential = confirmResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), ), ), ) @@ -262,4 +272,21 @@ 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 9e91614421..fe8243d9b7 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 @@ -5,8 +5,8 @@ import androidx.core.os.bundleOf import com.simprints.core.DeviceID import com.simprints.core.PackageVersionName import com.simprints.core.domain.response.AppErrorReason -import com.simprints.infra.events.event.domain.models.scope.Device import com.simprints.infra.orchestration.data.ActionResponse +import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.libsimprints.Constants import com.simprints.libsimprints.contracts.VersionsList import com.simprints.libsimprints.contracts.data.ConfidenceBand @@ -15,6 +15,8 @@ import com.simprints.libsimprints.contracts.data.Identification import com.simprints.libsimprints.contracts.data.Identification.Companion.toJson import com.simprints.libsimprints.contracts.data.RefusalForm import com.simprints.libsimprints.contracts.data.Verification +import org.json.JSONArray +import org.json.JSONObject import javax.inject.Inject import com.simprints.libsimprints.Identification as LegacyIdentification import com.simprints.libsimprints.RefusalForm as LegacyRefusalForm @@ -32,6 +34,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( Constants.SIMPRINTS_DEVICE_ID to deviceId, Constants.SIMPRINTS_APP_VERSION_NAME to appVersionName, Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, + HAS_CREDENTIAL to (response.externalCredential != null), ).appendDataPerContractVersion(response) { version -> when { version < VersionsList.INITIAL_REWORK -> putParcelable( @@ -42,6 +45,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( else -> putString(Constants.SIMPRINTS_ENROLMENT, Enrolment(response.enrolledGuid).toJson()) } }.appendCoSyncData(response.subjectActions) + .appendExternalCredential(response.externalCredential) is ActionResponse.IdentifyActionResponse -> bundleOf( Constants.SIMPRINTS_SESSION_ID to response.sessionId, @@ -50,6 +54,11 @@ internal class LibSimprintsResponseMapper @Inject constructor( Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, ).appendDataPerContractVersion(response) { version -> when { + response.isMultiFactorIdEnabled -> putString( + Constants.SIMPRINTS_IDENTIFICATIONS, + response.mapIdentificationsWithCredentials(), + ) + version < VersionsList.INITIAL_REWORK -> putParcelableArrayList( Constants.SIMPRINTS_IDENTIFICATIONS, response.identifications @@ -66,12 +75,15 @@ internal class LibSimprintsResponseMapper @Inject constructor( } } - is ActionResponse.ConfirmActionResponse -> bundleOf( - Constants.SIMPRINTS_SESSION_ID to response.sessionId, - Constants.SIMPRINTS_DEVICE_ID to deviceId, - Constants.SIMPRINTS_APP_VERSION_NAME to appVersionName, - Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, - ) + is ActionResponse.ConfirmActionResponse -> { + bundleOf( + Constants.SIMPRINTS_SESSION_ID to response.sessionId, + Constants.SIMPRINTS_DEVICE_ID to deviceId, + Constants.SIMPRINTS_APP_VERSION_NAME to appVersionName, + Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK to true, + HAS_CREDENTIAL to (response.externalCredential != null), + ).appendExternalCredential(response.externalCredential) + } is ActionResponse.VerifyActionResponse -> bundleOf( Constants.SIMPRINTS_SESSION_ID to response.sessionId, @@ -146,6 +158,33 @@ internal class LibSimprintsResponseMapper @Inject constructor( actions?.let { putString(Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS, it) } } + private fun Bundle.appendExternalCredential(credential: AppExternalCredential?) = apply { + if (credential != null) { + val credentialJson = + JSONObject() + .also { + it.put(SCANNED_CREDENTIAL_VALUE, credential.value) + it.put(SCANNED_CREDENTIAL_TYPE, credential.type) + }.toString() + putString(SCANNED_CREDENTIAL, credentialJson) + } + } + + private fun ActionResponse.IdentifyActionResponse.mapIdentificationsWithCredentials(): String = identifications + .map { identification -> + JSONObject() + .also { json -> + json.put(KEY_GUID, identification.guid) + json.put(KEY_CONFIDENCE_BAND, identification.matchConfidence.name) + json.put(KEY_CONFIDENCE, identification.confidenceScore.toFloat()) + json.put(KEY_IS_LINKED_TO_CREDENTIAL, identification.isLinkedToScannedCredential ?: false) + identification.isCredentialVerified?.let { + json.put(KEY_IS_CREDENTIAL_VERIFIED, it) + } + } + }.run(::JSONArray) + .toString() + private fun AppErrorReason.libSimprintsResultCode() = when (this) { AppErrorReason.UNEXPECTED_ERROR -> Constants.SIMPRINTS_UNEXPECTED_ERROR AppErrorReason.ROOTED_DEVICE -> Constants.SIMPRINTS_ROOTED_DEVICE @@ -182,5 +221,16 @@ internal class LibSimprintsResponseMapper @Inject constructor( companion object { internal const val RESULT_CODE_OVERRIDE = "result_code_override" + internal const val HAS_CREDENTIAL = "hasCredential" + internal const val SCANNED_CREDENTIAL = "scannedCredential" + internal const val SCANNED_CREDENTIAL_VALUE = "value" + internal const val SCANNED_CREDENTIAL_TYPE = "type" + + // TODO [MS-1190] Move implementation to LibSimprints. These constats are copies of com.simprints.libsimprints.contracts.data.Identification + private const val KEY_GUID = "guid" + private const val KEY_CONFIDENCE = "confidence" + private const val KEY_CONFIDENCE_BAND = "confidenceBand" + private const val KEY_IS_LINKED_TO_CREDENTIAL = "isLinkedToCredential" + private const val KEY_IS_CREDENTIAL_VERIFIED = "isVerified" } } diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt index 6661aa5441..766cabd0d3 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/usecases/GetEnrolmentCreationEventForSubjectUseCase.kt @@ -39,7 +39,10 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( ?.fromSubjectToEnrolmentCreationEvent() if (recordCreationEvent == null) { - Simber.e("Couldn't find enrolment for subjectActions", IllegalStateException("No enrolment record found for subjectId: $subjectId")) + Simber.e( + "Couldn't find enrolment for subjectActions", + IllegalStateException("No enrolment record found for subjectId: $subjectId"), + ) return null } @@ -47,11 +50,12 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( } private fun Subject.fromSubjectToEnrolmentCreationEvent() = EnrolmentRecordCreationEvent( - subjectId, - projectId, - moduleId, - attendantId, - EnrolmentRecordCreationEvent.buildBiometricReferences(fingerprintSamples, faceSamples, encoder), + subjectId = subjectId, + projectId = projectId, + moduleId = moduleId, + attendantId = attendantId, + biometricReferences = EnrolmentRecordCreationEvent.buildBiometricReferences(fingerprintSamples, faceSamples, encoder), + externalCredentials = externalCredentials, ) companion object { 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 f01529702e..4fe7c5a89b 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 @@ -2,8 +2,12 @@ package com.simprints.feature.clientapi import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.ext.junit.runners.* 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 @@ -16,20 +20,18 @@ import com.simprints.feature.clientapi.usecases.GetEnrolmentCreationEventForSubj import com.simprints.feature.clientapi.usecases.IsFlowCompletedWithErrorUseCase import com.simprints.feature.clientapi.usecases.SimpleEventReporter import com.simprints.infra.authstore.AuthStore +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.config.sync.ConfigManager 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.logging.persistent.PersistentLogger import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.MockKAnnotations -import io.mockk.coEvery -import io.mockk.coJustRun -import io.mockk.coVerify -import io.mockk.every +import io.mockk.* 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.Rule @@ -80,6 +82,9 @@ internal class ClientApiViewModelTest { @MockK lateinit var persistentLogger: PersistentLogger + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var viewModel: ClientApiViewModel @Before @@ -107,6 +112,7 @@ internal class ClientApiViewModelTest { configManager = configManager, timeHelper = timeHelper, persistentLogger = persistentLogger, + tokenizationProcessor = tokenizationProcessor, ) } @@ -148,7 +154,10 @@ internal class ClientApiViewModelTest { fun `handleEnrolResponse saves correct events`() = runTest { viewModel.handleEnrolResponse( mockRequest(), - mockk { every { guid } returns "guid" }, + mockk { + every { guid } returns "guid" + every { externalCredential } returns null + }, ) coVerify { @@ -165,7 +174,10 @@ internal class ClientApiViewModelTest { fun `handleIdentifyResponse saves correct events`() = runTest { viewModel.handleIdentifyResponse( mockRequest(), - mockk { every { identifications } returns emptyList() }, + mockk { + every { identifications } returns emptyList() + every { isMultiFactorIdEnabled } returns false + }, ) coVerify { @@ -180,7 +192,10 @@ internal class ClientApiViewModelTest { fun `handleConfirmResponse saves correct events`() = runTest { viewModel.handleConfirmResponse( mockRequest(), - mockk { every { identificationOutcome } returns true }, + mockk { + every { identificationOutcome } returns true + every { externalCredential } returns mockk() + }, ) coVerify { @@ -246,6 +261,80 @@ internal class ClientApiViewModelTest { viewModel.returnResponse.test().assertHasValue() } + @Test + fun `handleEnrolResponse with externalCredential decrypts and 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()) + + 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 + }, + ) + } + } + + private fun mockEnrolResponseWithCredential( + mockGuid: String, + credential: ExternalCredential?, + ): AppEnrolResponse = mockk { + every { guid } returns mockGuid + every { externalCredential } returns credential + } + + private fun mockExternalCredential( + mockId: String, + mockType: ExternalCredentialType, + ): ExternalCredential = mockk { + every { id } returns mockId + every { value } returns mockk() + every { type } returns mockType + } + + private fun setupDecryption( + project: Project, + returnValue: TokenizableString, + ) { + val projectId = "projectId" + every { authStore.signedInProjectId } returns projectId + coEvery { configManager.getProject(projectId) } 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/ActionToIntentMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/ActionToIntentMapperTest.kt index d2c545e9d3..1a39853093 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/ActionToIntentMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/ActionToIntentMapperTest.kt @@ -7,9 +7,8 @@ import com.simprints.feature.clientapi.models.LibSimprintsConstants import com.simprints.feature.clientapi.models.OdkConstants import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.testtools.common.syntax.assertThrows -import io.mockk.MockKAnnotations +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.verify import org.junit.Before import org.junit.Test @@ -74,5 +73,6 @@ class ActionToIntentMapperTest { actionIdentifier = ConfirmIdentityActionFactory.getIdentifier().copy(packageName = packageName), sessionId = "sessionId", confirmed = true, + externalCredential = null, ) } diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt index a99aa6a354..f9abf3fb49 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt @@ -1,8 +1,8 @@ package com.simprints.feature.clientapi.mappers.response import androidx.core.os.bundleOf -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat +import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.* import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.domain.response.AppMatchConfidence import com.simprints.feature.clientapi.mappers.request.requestFactories.ConfirmIdentityActionFactory @@ -31,6 +31,7 @@ class CommCareResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ).getBundle(CommCareConstants.COMMCARE_BUNDLE_KEY) ?: bundleOf() @@ -59,6 +60,7 @@ class CommCareResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -85,6 +87,7 @@ class CommCareResponseMapperTest { actionIdentifier = ConfirmIdentityActionFactory.getIdentifier(), sessionId = "sessionId", confirmed = true, + externalCredential = null, ), ).getBundle(CommCareConstants.COMMCARE_BUNDLE_KEY) ?: bundleOf() 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 05e28f5260..5d3140035f 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 @@ -2,17 +2,24 @@ package com.simprints.feature.clientapi.mappers.response import androidx.test.ext.junit.runners.* import com.google.common.truth.Truth.* +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.domain.response.AppMatchConfidence +import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.feature.clientapi.mappers.request.requestFactories.ConfirmIdentityActionFactory import com.simprints.feature.clientapi.mappers.request.requestFactories.EnrolActionFactory 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.HAS_CREDENTIAL +import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL +import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL_TYPE +import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL_VALUE import com.simprints.infra.orchestration.data.ActionResponse import com.simprints.infra.orchestration.data.responses.AppMatchResult import com.simprints.libsimprints.Constants import com.simprints.libsimprints.contracts.VersionsList +import io.mockk.* import org.junit.Test import org.junit.runner.RunWith import com.simprints.libsimprints.Identification as LegacyIdentification @@ -33,6 +40,7 @@ class LibSimprintsResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) @@ -55,6 +63,7 @@ class LibSimprintsResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) @@ -83,6 +92,7 @@ class LibSimprintsResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -110,6 +120,7 @@ class LibSimprintsResponseMapperTest { matchConfidence = AppMatchConfidence.MEDIUM, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -126,11 +137,18 @@ class LibSimprintsResponseMapperTest { @Test fun `correctly maps confirm response`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val expectedJson = "{\"$SCANNED_CREDENTIAL_VALUE\":\"$expectedValue\",\"$SCANNED_CREDENTIAL_TYPE\":\"$expectedType\"}" val extras = mapper( ActionResponse.ConfirmActionResponse( actionIdentifier = ConfirmIdentityActionFactory.getIdentifier(), sessionId = "sessionId", confirmed = true, + externalCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + }, ), ) @@ -138,6 +156,8 @@ class LibSimprintsResponseMapperTest { assertThat(extras.getString(Constants.SIMPRINTS_DEVICE_ID)).isEqualTo("deviceId") assertThat(extras.getString(Constants.SIMPRINTS_APP_VERSION_NAME)).isEqualTo("appVersionName") assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + assertThat(extras.getBoolean(HAS_CREDENTIAL)).isTrue() + assertThat(extras.getString(SCANNED_CREDENTIAL)).isEqualTo(expectedJson) } @Test @@ -381,4 +401,146 @@ class LibSimprintsResponseMapperTest { ) } } + + @Test + fun `correctly maps enrol response with external credential`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val expectedJson = "{\"$SCANNED_CREDENTIAL_VALUE\":\"$expectedValue\",\"$SCANNED_CREDENTIAL_TYPE\":\"$expectedType\"}" + + val extras = mapper( + ActionResponse.EnrolActionResponse( + actionIdentifier = EnrolActionFactory.getIdentifier(), + sessionId = "sessionId", + enrolledGuid = "guid", + subjectActions = "subjects", + externalCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + }, + ), + ) + + assertThat(extras.getBoolean(HAS_CREDENTIAL)).isTrue() + assertThat(extras.getString(SCANNED_CREDENTIAL)).isEqualTo(expectedJson) + } + + @Test + fun `correctly maps confirm response without external credential`() { + val extras = mapper( + ActionResponse.ConfirmActionResponse( + actionIdentifier = ConfirmIdentityActionFactory.getIdentifier(), + sessionId = "sessionId", + confirmed = true, + externalCredential = null, + ), + ) + + assertThat(extras.getString(Constants.SIMPRINTS_SESSION_ID)).isEqualTo("sessionId") + assertThat(extras.getString(Constants.SIMPRINTS_DEVICE_ID)).isEqualTo("deviceId") + assertThat(extras.getString(Constants.SIMPRINTS_APP_VERSION_NAME)).isEqualTo("appVersionName") + assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + assertThat(extras.getBoolean(HAS_CREDENTIAL)).isFalse() + assertThat(extras.keySet()).doesNotContain(SCANNED_CREDENTIAL) + } + + @Test + fun `correctly maps identify response with multi-factor ID enabled`() { + val identification1 = AppMatchResult( + guid = "guid-1", + confidenceScore = 100, + matchConfidence = AppMatchConfidence.MEDIUM, + isLinkedToScannedCredential = true, + isCredentialVerified = true, + ) + val identification2 = AppMatchResult( + guid = "guid-2", + confidenceScore = 75, + matchConfidence = AppMatchConfidence.LOW, + isLinkedToScannedCredential = false, + isCredentialVerified = null, + ) + + val expectedIdentifications = "[${identification1.toResponseJson()},${identification2.toResponseJson()}]" + + val extras = mapper( + ActionResponse.IdentifyActionResponse( + actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), + sessionId = "sessionId", + identifications = listOf(identification1, identification2), + isMultiFactorIdEnabled = true, + ), + ) + + assertThat(extras.getString(Constants.SIMPRINTS_SESSION_ID)).isEqualTo("sessionId") + assertThat(extras.getString(Constants.SIMPRINTS_DEVICE_ID)).isEqualTo("deviceId") + assertThat(extras.getString(Constants.SIMPRINTS_APP_VERSION_NAME)).isEqualTo("appVersionName") + assertThat(extras.getString(Constants.SIMPRINTS_IDENTIFICATIONS)).isEqualTo(expectedIdentifications) + assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + } + + @Test + fun `mapIdentificationsWithCredentials omits isVerified when null`() { + val guid = "guid" + val extras = mapper( + ActionResponse.IdentifyActionResponse( + actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), + sessionId = "sessionId", + identifications = listOf( + AppMatchResult( + guid = guid, + confidenceScore = 80, + matchConfidence = AppMatchConfidence.MEDIUM, + isLinkedToScannedCredential = false, + isCredentialVerified = null, + ), + ), + isMultiFactorIdEnabled = true, + ), + ) + + val identificationsJson = extras.getString(Constants.SIMPRINTS_IDENTIFICATIONS) + assertThat(identificationsJson).contains("\"guid\":\"$guid\"") + assertThat(identificationsJson).contains("\"isLinkedToCredential\":false") + assertThat(identificationsJson).doesNotContain("isVerified") + } + + @Test + fun `identify response uses legacy format when multi-factor ID is disabled`() { + val guid = "guid" + val confidenceScore = 100 + val extras = mapper( + ActionResponse.IdentifyActionResponse( + actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), + sessionId = "sessionId", + identifications = listOf( + AppMatchResult( + guid = guid, + confidenceScore = confidenceScore, + matchConfidence = AppMatchConfidence.MEDIUM, + isLinkedToScannedCredential = true, + isCredentialVerified = true, + ), + ), + isMultiFactorIdEnabled = false, + ), + ) + + assertThat(extras.getParcelableArrayList(Constants.SIMPRINTS_IDENTIFICATIONS)).containsExactly( + LegacyIdentification(guid = guid, confidence = confidenceScore, tier = LegacyTier.TIER_2), + ) + } + + private fun AppMatchResult.toResponseJson(): String { + val jsonBuilder = StringBuilder() + jsonBuilder.append("{\"guid\":\"$guid\"") + jsonBuilder.append(",\"confidenceBand\":\"${matchConfidence.name}\"") + jsonBuilder.append(",\"confidence\":$confidenceScore") + jsonBuilder.append(",\"isLinkedToCredential\":$isLinkedToScannedCredential") + isCredentialVerified?.let { + jsonBuilder.append(",\"isVerified\":$it") + } + jsonBuilder.append("}") + return jsonBuilder.toString() + } } diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt index b4f234d861..6b1349d889 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt @@ -1,7 +1,7 @@ package com.simprints.feature.clientapi.mappers.response -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat +import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.* import com.simprints.core.domain.response.AppErrorReason import com.simprints.core.domain.response.AppMatchConfidence import com.simprints.feature.clientapi.mappers.request.requestFactories.ConfirmIdentityActionFactory @@ -27,6 +27,7 @@ class OdkResponseMapperTest { sessionId = "sessionId", enrolledGuid = "guid", subjectActions = "subjects", + externalCredential = null, ), ) @@ -53,6 +54,7 @@ class OdkResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -72,6 +74,7 @@ class OdkResponseMapperTest { actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), sessionId = "sessionId", identifications = listOf(), + isMultiFactorIdEnabled = false, ), ) @@ -85,6 +88,7 @@ class OdkResponseMapperTest { actionIdentifier = ConfirmIdentityActionFactory.getIdentifier(), sessionId = "sessionId", confirmed = true, + externalCredential = null, ), ) diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt index 8f8606c54c..0affc90d14 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/ConsentViewModel.kt @@ -82,20 +82,23 @@ internal class ConsentViewModel @Inject constructor( selectedTabIndex: Int, ): ConsentViewState { val allowParentalConsent = projectConfig.consent.allowParentalConsent + val isMultiFactorIdEnabled = projectConfig.multifactorId?.allowedExternalCredentials?.isNotEmpty() ?: false return ConsentViewState( showLogo = projectConfig.consent.displaySimprintsLogo, showParentalConsent = allowParentalConsent, consentTextBuilder = GeneralConsentTextHelper( - projectConfig.consent, - projectConfig.general.modalities, - consentType, + config = projectConfig.consent, + modalities = projectConfig.general.modalities, + consentType = consentType, + isMultiFactorIdEnabled = isMultiFactorIdEnabled, ), parentalTextBuilder = if (allowParentalConsent) { ParentalConsentTextHelper( - projectConfig.consent, - projectConfig.general.modalities, - consentType, + config = projectConfig.consent, + modalities = projectConfig.general.modalities, + consentType = consentType, + isMultiFactorIdEnabled = isMultiFactorIdEnabled, ) } else { null diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt index 6fcb535638..c8e234d80e 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelper.kt @@ -10,17 +10,21 @@ internal data class GeneralConsentTextHelper( private val config: ConsentConfiguration, private val modalities: List, private val consentType: ConsentType, + private val isMultiFactorIdEnabled: Boolean, ) { // TODO All the `getString(id).format(arg,arg)` calls should be `getString(id,arg,arg)` one strings are fixed - // First argument in consent text should always be program name, second is modality specific access/use case text fun assembleText(context: Context) = StringBuilder() .apply { val modalityUseCase = getModalitySpecificUseCaseText(context, modalities) - val modalityAccess = getModalitySpecificAccessText(context, modalities) + val modalityAccess = + getModalitySpecificAccessText(context, modalities) + getMultiFactorIdAccessText(context, isMultiFactorIdEnabled) - filterAppRequestForConsent(context, consentType, config, modalityUseCase) - filterForDataSharingOptions(context, config, modalityUseCase, modalityAccess) + val requestModalityUseCase = modalityUseCase + getMultiFactorIdUseCaseText(context, isMultiFactorIdEnabled) + val dataSharingModalityUseCase = modalityUseCase + getMultiFactorIdSharingText(context, isMultiFactorIdEnabled) + + filterAppRequestForConsent(context, consentType, config, requestModalityUseCase) + filterForDataSharingOptions(context, config, dataSharingModalityUseCase, modalityAccess) }.toString() private fun StringBuilder.filterAppRequestForConsent( @@ -134,4 +138,42 @@ internal data class GeneralConsentTextHelper( Modality.FACE -> context.getString(R.string.consent_biometrics_access_face) Modality.FINGERPRINT -> context.getString(R.string.consent_biometrics_access_fingerprint) } + + private fun getMultiFactorIdUseCaseText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_general), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdAccessText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_credentials_access), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdSharingText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_your_id), + ).joinToString(separator = " ") + } else { + "" + } } diff --git a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt index bf73ec7eb4..66ae44a2f7 100644 --- a/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt +++ b/feature/consent/src/main/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelper.kt @@ -10,17 +10,20 @@ internal data class ParentalConsentTextHelper( private val config: ConsentConfiguration, private val modalities: List, private val consentType: ConsentType, + private val isMultiFactorIdEnabled: Boolean, ) { // TODO All the `getString(id).format(arg,arg)` calls should be `getString(id,arg,arg)` one strings are fixed - // First argument in consent text should always be program name, second is modality specific access/use case text fun assembleText(context: Context): String = StringBuilder() .apply { val modalityUseCase = getModalitySpecificUseCaseText(context, modalities) - val modalityAccess = getModalitySpecificAccessText(context, modalities) + val modalityAccess = + getModalitySpecificAccessText(context, modalities) + getMultiFactorIdAccessText(context, isMultiFactorIdEnabled) - filterAppRequestForParentalConsent(context, consentType, config, modalityUseCase) - extractDataSharingOptions(context, config, modalityUseCase, modalityAccess) + val requestModalityUseCase = modalityUseCase + getMultiFactorIdUseCaseText(context, isMultiFactorIdEnabled) + val dataSharingModalityUseCase = modalityUseCase + getMultiFactorIdSharingText(context, isMultiFactorIdEnabled) + filterAppRequestForParentalConsent(context, consentType, config, requestModalityUseCase) + extractDataSharingOptions(context, config, dataSharingModalityUseCase, modalityAccess) }.toString() private fun StringBuilder.filterAppRequestForParentalConsent( @@ -136,4 +139,42 @@ internal data class ParentalConsentTextHelper( Modality.FINGERPRINT -> context.getString(R.string.consent_biometrics_access_fingerprint) else -> "" } + + private fun getMultiFactorIdAccessText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_credentials_parental_access), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdUseCaseText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_parental_general), + ).joinToString(separator = " ") + } else { + "" + } + + private fun getMultiFactorIdSharingText( + context: Context, + isMultiFactorIdEnabled: Boolean, + ): String = if (isMultiFactorIdEnabled) { + listOf( + ",", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_parental_your_id), + ).joinToString(separator = " ") + } else { + "" + } } diff --git a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt index 985d9c4087..cda8dba7b1 100644 --- a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt +++ b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/GeneralConsentTextHelperTest.kt @@ -40,6 +40,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -63,6 +64,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -86,6 +88,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -109,6 +112,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -132,6 +136,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.VERIFY, + false, ).assembleText(context) val expectedString = context @@ -155,6 +160,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -178,6 +184,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -201,6 +208,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -225,6 +233,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -249,6 +258,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -273,6 +283,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -296,6 +307,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -318,6 +330,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -340,6 +353,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -362,6 +376,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -384,6 +399,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -407,6 +423,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -429,6 +446,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContainMatch("\\.\\w") @@ -448,6 +466,7 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContainMatch("^\\s.*") @@ -467,11 +486,84 @@ class GeneralConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(generalConsentText).doesNotContain(" ") } + @Test + fun `should add multi-factor ID use case text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.ENROL, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + val expectedText = String.format( + ", %s %s", + context.getString(R.string.consent_biometric_concat_modalities), + context.getString(R.string.consent_credentials_general), + ) + + assertThat(generalConsentText).contains(expectedText) + } + + @Test + fun `should add multi-factor ID sharing text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = true, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + // Just check for the key identifying phrase from the credentials text + assertThat(generalConsentText).contains("national ID document or other external credentials") + } + + @Test + fun `should add multi-factor ID access text when isMultiFactorIdEnabled is true`() { + val generalConsentText = GeneralConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + val expectedText = String.format( + ", %s", + context.getString(R.string.consent_credentials_access), + ) + + assertThat(generalConsentText).contains(expectedText) + } + private fun configWithPrompt(prompt: ConsentConfiguration.ConsentPromptConfiguration) = ConsentConfiguration( programName = PROGRAM_NAME, organizationName = ORGANIZATION_NAME, diff --git a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt index 7fe9694c79..542eae823a 100644 --- a/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt +++ b/feature/consent/src/test/java/com/simprints/feature/consent/screens/consent/helpers/ParentalConsentTextHelperTest.kt @@ -40,6 +40,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -63,6 +64,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -86,6 +88,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -109,6 +112,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.ENROL, + false, ).assembleText(context) val expectedString = context @@ -132,6 +136,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.VERIFY, + false, ).assembleText(context) val expectedString = context @@ -155,6 +160,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -178,6 +184,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -201,6 +208,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -225,6 +233,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -249,6 +258,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT, GeneralConfiguration.Modality.FACE), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -273,6 +283,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -296,6 +307,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -318,6 +330,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -340,6 +353,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -362,6 +376,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -384,6 +399,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -407,6 +423,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) val expectedString = context @@ -429,6 +446,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContainMatch("\\.\\w") @@ -448,6 +466,7 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContainMatch("^\\s.*") @@ -467,11 +486,72 @@ class ParentalConsentTextHelperTest { ), listOf(GeneralConfiguration.Modality.FINGERPRINT), ConsentType.IDENTIFY, + false, ).assembleText(context) assertThat(parentalConsentText).doesNotContain(" ") } + @Test + fun `should add multi-factor ID use case text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.ENROL, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains(context.getString(R.string.consent_credentials_parental_general)) + } + + @Test + fun `should add multi-factor ID access text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = false, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains(context.getString(R.string.consent_credentials_parental_access)) + } + + @Test + fun `should add multi-factor ID sharing text when isMultiFactorIdEnabled is true`() { + val parentalConsentText = ParentalConsentTextHelper( + configWithPrompt( + ConsentConfiguration.ConsentPromptConfiguration( + enrolmentVariant = ConsentConfiguration.ConsentEnrolmentVariant.STANDARD, + dataSharedWithPartner = true, + dataUsedForRAndD = false, + privacyRights = false, + confirmation = false, + ), + ), + listOf(GeneralConfiguration.Modality.FINGERPRINT), + ConsentType.IDENTIFY, + isMultiFactorIdEnabled = true, + ).assembleText(context) + + assertThat(parentalConsentText).contains("child's national ID document or other external credentials") + } + private fun configWithPrompt(prompt: ConsentConfiguration.ConsentPromptConfiguration) = ConsentConfiguration( programName = PROGRAM_NAME, organizationName = ORGANIZATION_NAME, diff --git a/feature/enrol-last-biometric/build.gradle.kts b/feature/enrol-last-biometric/build.gradle.kts index dec9378420..c1699c9563 100644 --- a/feature/enrol-last-biometric/build.gradle.kts +++ b/feature/enrol-last-biometric/build.gradle.kts @@ -10,6 +10,7 @@ android { dependencies { implementation(project(":feature:alert")) + implementation(project(":feature:external-credential")) implementation(project(":infra:event-sync")) implementation(project(":infra:config-store")) implementation(project(":infra:config-sync")) 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 a3c6c5bc94..b4c2fca5c1 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 @@ -1,7 +1,10 @@ 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 +@ExcludedFromGeneratedTestCoverageReports("Data class") object EnrolLastBiometricContract { val DESTINATION = R.id.enrolLastBiometricFragment @@ -10,10 +13,12 @@ object EnrolLastBiometricContract { userId: TokenizableString, moduleId: TokenizableString, steps: List, + scannedCredential: ScannedCredential?, ) = EnrolLastBiometricParams( projectId = projectId, userId = userId, moduleId = moduleId, steps = steps, + scannedCredential = scannedCredential, ) } 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 1f38f1fb95..57040125ff 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 @@ -1,18 +1,22 @@ package com.simprints.feature.enrollast import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports 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.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.Finger import com.simprints.infra.config.store.models.FingerprintConfiguration @Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") data class EnrolLastBiometricParams( val projectId: String, val userId: TokenizableString, val moduleId: TokenizableString, val steps: List, + val scannedCredential: ScannedCredential?, ) : StepParams sealed class EnrolLastBiometricStepResult : StepParams { 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 872e6874c8..e7abcc16f4 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,9 +1,11 @@ package com.simprints.feature.enrollast import androidx.annotation.Keep +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.step.StepResult @Keep data class EnrolLastBiometricResult( val newSubjectId: String?, + val externalCredential: ExternalCredential?, ) : 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 9411e13490..b95dc0bafa 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 @@ -6,6 +6,8 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetDialog +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 @@ -21,6 +23,8 @@ import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.DUPLICATE_ENROLMENTS 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.view.ScannedCredentialDialog import com.simprints.infra.config.store.models.GeneralConfiguration.Modality import com.simprints.infra.events.event.domain.models.AlertScreenEvent import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION @@ -37,6 +41,7 @@ import com.simprints.infra.resources.R as IDR internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_last) { private val viewModel: EnrolLastBiometricViewModel by viewModels() private val params: EnrolLastBiometricParams by navigationParams() + private var dialog: BottomSheetDialog? = null override fun onViewCreated( view: View, @@ -50,17 +55,35 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las viewLifecycleOwner, R.id.enrolLastBiometricFragment, AlertContract.DESTINATION, - ) { finishWithSubjectId(null) } + ) { finish(newSubjectId = null, credential = null) } - viewModel.finish.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { finishWithResult(it) }) + initObservers() viewModel.onViewCreated(params) } + private fun initObservers() { + viewModel.finish.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { finishWithResult(it) }) + viewModel.showAddCredentialDialog.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver(::displayCredentialDialog), + ) + } + + private fun displayCredentialDialog(credentialDialogItem: CredentialDialogItem) { + dialog = ScannedCredentialDialog( + context = requireActivity(), + credential = credentialDialogItem.scannedCredential, + displayedCredential = credentialDialogItem.displayedCredential, + onConfirm = { viewModel.enrolBiometric(params, isAddingCredential = true) }, + onSkip = { viewModel.enrolBiometric(params, isAddingCredential = false) }, + ).also(ScannedCredentialDialog::show) + } + private fun finishWithResult(result: EnrolLastState) = when (result) { 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() - finishWithSubjectId(result.newGuid) + finish(result.newGuid, result.externalCredential) } } @@ -103,7 +126,10 @@ internal class EnrolLastBiometricFragment : Fragment(R.layout.fragment_enrol_las } }.let { getString(it) } - private fun finishWithSubjectId(newSubjectId: String?) { - findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId)) + private fun finish( + newSubjectId: String?, + credential: ExternalCredential?, + ) { + findNavController().finishWithResult(this, EnrolLastBiometricResult(newSubjectId, credential)) } } 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 0216e1849d..0522c2149d 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 @@ -4,20 +4,28 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.livedata.send import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.EnrolLastState.ErrorType.GENERAL_ERROR +import com.simprints.feature.enrollast.screen.model.CredentialDialogItem import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase 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.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.EnrolmentEventV4 +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ENROLMENT import com.simprints.infra.logging.Simber @@ -32,21 +40,37 @@ internal class EnrolLastBiometricViewModel @Inject constructor( private val eventRepository: SessionEventRepository, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val checkForDuplicateEnrolments: CheckForDuplicateEnrolmentsUseCase, + private val tokenizationProcessor: TokenizationProcessor, private val buildSubject: BuildSubjectUseCase, ) : ViewModel() { val finish: LiveData> get() = _finish - private var _finish = MutableLiveData>() + private val _finish = MutableLiveData>() + + val showAddCredentialDialog: LiveData> + get() = _showAddCredentialDialog + private val _showAddCredentialDialog = MutableLiveData>() private var enrolWasAttempted = false fun onViewCreated(params: EnrolLastBiometricParams) { - if (!enrolWasAttempted) { - enrolBiometric(params) + viewModelScope.launch { + params.scannedCredential?.let { scannedCredential -> + if (isCredentialLinkedToAnotherSubject(scannedCredential, params.projectId)) { + displayAddCredentialDialog(scannedCredential, params.projectId) + return@launch + } + } + if (!enrolWasAttempted) { + enrolBiometric(params, isAddingCredential = true) + } } } - fun enrolBiometric(params: EnrolLastBiometricParams) = viewModelScope.launch { + fun enrolBiometric( + params: EnrolLastBiometricParams, + isAddingCredential: Boolean, + ) = viewModelScope.launch { enrolWasAttempted = true val projectConfig = configManager.getProjectConfiguration() @@ -54,10 +78,11 @@ internal class EnrolLastBiometricViewModel @Inject constructor( val modalities = projectConfig.general.modalities val previousLastEnrolmentResult = getPreviousEnrolmentResult(params.steps) + val scannedCredential = params.scannedCredential?.takeIf { isAddingCredential } if (previousLastEnrolmentResult != null) { _finish.send( previousLastEnrolmentResult.subjectId - ?.let { EnrolLastState.Success(it) } + ?.let { subjectId -> EnrolLastState.Success(subjectId, scannedCredential?.toExternalCredential(subjectId)) } ?: EnrolLastState.Failed(GENERAL_ERROR, modalities), ) return@launch @@ -69,36 +94,70 @@ internal class EnrolLastBiometricViewModel @Inject constructor( } try { - val subject = buildSubject(params) + val subject = buildSubject(params, isAddingCredential = isAddingCredential) registerEvent(subject) enrolmentRecordRepository.performActions(listOf(SubjectAction.Creation(subject)), project) - _finish.send(EnrolLastState.Success(subject.subjectId)) + _finish.send(EnrolLastState.Success(subject.subjectId, scannedCredential?.toExternalCredential(subject.subjectId))) } catch (t: Throwable) { Simber.e("Enrolment failed", t, tag = ENROLMENT) _finish.send(EnrolLastState.Failed(GENERAL_ERROR, modalities)) } } + private suspend fun displayAddCredentialDialog( + scannedCredential: ScannedCredential, + projectId: String, + ) { + val project = configManager.getProject(projectId) + val decrypted = tokenizationProcessor.decrypt( + encrypted = scannedCredential.credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as TokenizableString.Raw + _showAddCredentialDialog.send(CredentialDialogItem(scannedCredential, decrypted)) + } + + private suspend fun isCredentialLinkedToAnotherSubject( + scannedCredential: ScannedCredential?, + projectId: String, + ): Boolean { + if (scannedCredential == null) return false + + return enrolmentRecordRepository + .load( + SubjectQuery( + projectId = projectId, + externalCredential = scannedCredential.credential, + ), + ).isNotEmpty() + } + private fun getPreviousEnrolmentResult(steps: List) = steps.filterIsInstance().firstOrNull() private suspend fun registerEvent(subject: Subject) { Simber.d("Register events for enrolments", tag = ENROLMENT) - - val biometricReferenceIds = eventRepository + val events = eventRepository .getEventsInCurrentSession() + + val biometricReferenceIds = events .filterIsInstance() .sortedByDescending { it.payload.createdAt } .map { it.payload.id } + val externalCredentialIds = events + .filterIsInstance() + .map { it.payload.id } + eventRepository.addOrUpdateEvent( EnrolmentEventV4( - timeHelper.now(), - subject.subjectId, - subject.projectId, - subject.moduleId, - subject.attendantId, - biometricReferenceIds, + createdAt = timeHelper.now(), + subjectId = subject.subjectId, + projectId = subject.projectId, + moduleId = subject.moduleId, + attendantId = subject.attendantId, + biometricReferenceIds = biometricReferenceIds, + externalCredentialIds = externalCredentialIds, ), ) } 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 538baebd41..2f5a2f7d27 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 @@ -1,17 +1,24 @@ package com.simprints.feature.enrollast.screen +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.infra.config.store.models.GeneralConfiguration +@ExcludedFromGeneratedTestCoverageReports("Data class") internal sealed class EnrolLastState { + @ExcludedFromGeneratedTestCoverageReports("Data class") data class Success( val newGuid: String, + val externalCredential: ExternalCredential?, ) : EnrolLastState() + @ExcludedFromGeneratedTestCoverageReports("Data class") data class Failed( val errorType: ErrorType, val modalities: List, ) : EnrolLastState() + @ExcludedFromGeneratedTestCoverageReports("Data class") enum class ErrorType { NO_MATCH_RESULTS, DUPLICATE_ENROLMENTS, 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 new file mode 100644 index 0000000000..afe4cc1011 --- /dev/null +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/model/CredentialDialogItem.kt @@ -0,0 +1,11 @@ +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 + +@ExcludedFromGeneratedTestCoverageReports("Data struct") +internal data class CredentialDialogItem( + val scannedCredential: ScannedCredential, + val displayedCredential: TokenizableString.Raw, +) diff --git a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt index 8de9c976e6..6341a5a27d 100644 --- a/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt +++ b/feature/enrol-last-biometric/src/main/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCase.kt @@ -8,6 +8,8 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.FaceTemplateCaptureResult import com.simprints.feature.enrollast.FingerTemplateCaptureResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.models.Finger import com.simprints.infra.enrolment.records.repository.domain.models.Subject import com.simprints.infra.eventsync.sync.common.SubjectFactory @@ -19,19 +21,36 @@ internal class BuildSubjectUseCase @Inject constructor( private val timeHelper: TimeHelper, private val subjectFactory: SubjectFactory, ) { - operator fun invoke(params: EnrolLastBiometricParams): Subject = subjectFactory.buildSubject( - UUID.randomUUID().toString(), - params.projectId, - params.userId, - params.moduleId, - createdAt = Date(timeHelper.now().ms), - fingerprintSamples = getFingerprintCaptureResult(params.steps) - ?.let { result -> result.results.map { fingerprintSample(result.referenceId, it) } } - .orEmpty(), - faceSamples = getFaceCaptureResult(params.steps) - ?.let { result -> result.results.map { faceSample(result.referenceId, it) } } - .orEmpty(), - ) + operator fun invoke( + params: EnrolLastBiometricParams, + isAddingCredential: Boolean, + ): Subject { + val subjectId = UUID.randomUUID().toString() + val externalCredentials = if (isAddingCredential) { + getExternalCredentialResult(params.scannedCredential, subjectId)?.let(::listOf) ?: emptyList() + } else { + emptyList() + } + return subjectFactory.buildSubject( + subjectId = subjectId, + projectId = params.projectId, + attendantId = params.userId, + moduleId = params.moduleId, + createdAt = Date(timeHelper.now().ms), + fingerprintSamples = getFingerprintCaptureResult(params.steps) + ?.let { result -> result.results.map { fingerprintSample(result.referenceId, it) } } + .orEmpty(), + faceSamples = getFaceCaptureResult(params.steps) + ?.let { result -> result.results.map { faceSample(result.referenceId, it) } } + .orEmpty(), + externalCredentials = externalCredentials, + ) + } + + private fun getExternalCredentialResult( + credential: ScannedCredential?, + subjectId: String, + ) = credential?.toExternalCredential(subjectId) private fun getFingerprintCaptureResult(steps: List) = steps .filterIsInstance() 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 e3421e3011..80f74754c8 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 @@ -10,10 +10,15 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.screen.usecase.BuildSubjectUseCase import com.simprints.feature.enrollast.screen.usecase.CheckForDuplicateEnrolmentsUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +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.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent.BiometricReferenceCreationPayload import com.simprints.infra.events.event.domain.models.EnrolmentEventV4 @@ -43,6 +48,9 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var projectConfig: ProjectConfiguration + @MockK + lateinit var project: Project + @MockK lateinit var eventRepository: SessionEventRepository @@ -58,6 +66,12 @@ internal class EnrolLastBiometricViewModelTest { @MockK lateinit var subject: Subject + @MockK + lateinit var scannedCredential: ScannedCredential + + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var viewModel: EnrolLastBiometricViewModel @Before @@ -75,12 +89,13 @@ internal class EnrolLastBiometricViewModelTest { ) viewModel = EnrolLastBiometricViewModel( - timeHelper, - configManager, - eventRepository, - enrolmentRecordRepository, - checkForDuplicateEnrolments, - buildSubject, + timeHelper = timeHelper, + configManager = configManager, + eventRepository = eventRepository, + enrolmentRecordRepository = enrolmentRecordRepository, + checkForDuplicateEnrolments = checkForDuplicateEnrolments, + tokenizationProcessor = tokenizationProcessor, + buildSubject = buildSubject, ) } @@ -112,13 +127,14 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult("previousSubjectId"), ), ), + isAddingCredential = false, ) val result = viewModel.finish .test() .value() .getContentIfNotHandled() - assertThat(result).isEqualTo(EnrolLastState.Success("previousSubjectId")) + assertThat(result).isEqualTo(EnrolLastState.Success(newGuid = "previousSubjectId", externalCredential = null)) } @Test @@ -129,6 +145,7 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult("previousSubjectId"), ), ), + isAddingCredential = false, ) coVerify(exactly = 0) { eventRepository.addOrUpdateEvent(any()) } @@ -143,6 +160,7 @@ internal class EnrolLastBiometricViewModelTest { EnrolLastBiometricStepResult.EnrolLastBiometricsResult(null), ), ), + isAddingCredential = false, ) val result = @@ -157,7 +175,7 @@ internal class EnrolLastBiometricViewModelTest { fun `returns failure when has duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns EnrolLastState.ErrorType.DUPLICATE_ENROLMENTS - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish @@ -171,9 +189,9 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `returns success when no duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish .test() @@ -185,9 +203,9 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `saves event and record when no duplicate enrolments`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) coVerify { eventRepository.addOrUpdateEvent(any()) } coVerify { enrolmentRecordRepository.performActions(any(), any()) } @@ -196,10 +214,10 @@ internal class EnrolLastBiometricViewModelTest { @Test fun `returns failure record saving fails`() = runTest { every { checkForDuplicateEnrolments.invoke(any(), any()) } returns null - coEvery { buildSubject.invoke(any()) } returns subject + coEvery { buildSubject.invoke(any(), any()) } returns subject coEvery { enrolmentRecordRepository.performActions(any(), any()) } throws Exception() - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) val result = viewModel.finish @@ -232,7 +250,7 @@ internal class EnrolLastBiometricViewModelTest { biometricReferenceCreationEvent1, ) - viewModel.enrolBiometric(createParams(listOf())) + viewModel.enrolBiometric(createParams(listOf()), isAddingCredential = false) coVerify { eventRepository.addOrUpdateEvent( @@ -245,11 +263,39 @@ internal class EnrolLastBiometricViewModelTest { } } + @Test + fun `shows add credential dialog when scanned credential is linked to another subject`() = runTest { + val decryptedCredential = "decryptedCredential".asTokenizableRaw() + coEvery { enrolmentRecordRepository.load(any()) } returns listOf(subject) + coEvery { configManager.getProject(PROJECT_ID) } returns project + coEvery { + tokenizationProcessor.decrypt( + encrypted = scannedCredential.credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) + } returns decryptedCredential + + viewModel.onViewCreated(createParams(listOf())) + + val result = viewModel.showAddCredentialDialog + .test() + .value() + .getContentIfNotHandled() + + assertThat(result).isNotNull() + assertThat(result?.scannedCredential).isEqualTo(scannedCredential) + assertThat(result?.displayedCredential).isEqualTo(decryptedCredential) + coVerify(exactly = 0) { buildSubject.invoke(any(), any()) } + coVerify(exactly = 0) { enrolmentRecordRepository.performActions(any(), any()) } + } + private fun createParams(steps: List) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, + scannedCredential = scannedCredential, ) companion object { diff --git a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt index 2145e1a2c0..89f970bc80 100644 --- a/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt +++ b/feature/enrol-last-biometric/src/test/java/com/simprints/feature/enrollast/screen/usecase/BuildSubjectUseCaseTest.kt @@ -1,7 +1,10 @@ package com.simprints.feature.enrollast.screen.usecase 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.fingerprint.IFingerIdentifier +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 @@ -9,6 +12,8 @@ import com.simprints.feature.enrollast.EnrolLastBiometricParams import com.simprints.feature.enrollast.EnrolLastBiometricStepResult import com.simprints.feature.enrollast.FaceTemplateCaptureResult import com.simprints.feature.enrollast.FingerTemplateCaptureResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.config.store.models.Finger import com.simprints.infra.eventsync.sync.common.SubjectFactory import com.simprints.testtools.unit.EncodingUtilsImplForTests @@ -23,6 +28,9 @@ class BuildSubjectUseCaseTest { @MockK private lateinit var timeHelper: TimeHelper + @MockK + private lateinit var scannedCredential: ScannedCredential + private lateinit var useCase: BuildSubjectUseCase private lateinit var subjectFactory: SubjectFactory @@ -40,7 +48,7 @@ class BuildSubjectUseCaseTest { @Test fun `has no samples if no steps provided`() { - val result = useCase(createParams(emptyList())) + val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = false) assertThat(result.fingerprintSamples).isEmpty() assertThat(result.faceSamples).isEmpty() @@ -50,12 +58,14 @@ class BuildSubjectUseCaseTest { fun `has no samples if no valid steps provided`() { val result = useCase( createParams( - listOf( + steps = listOf( EnrolLastBiometricStepResult.EnrolLastBiometricsResult(null), EnrolLastBiometricStepResult.FingerprintMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FaceMatchResult(emptyList(), mockk()), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isEmpty() @@ -66,7 +76,7 @@ class BuildSubjectUseCaseTest { fun `maps first available fingerprint capture step results`() { val result = useCase( createParams( - listOf( + steps = listOf( EnrolLastBiometricStepResult.FingerprintMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FingerprintCaptureResult( REFERENCE_ID, @@ -77,7 +87,9 @@ class BuildSubjectUseCaseTest { listOf(mockFingerprintResults(Finger.LEFT_THUMB)), ), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isNotEmpty() @@ -89,7 +101,7 @@ class BuildSubjectUseCaseTest { val result = useCase( createParams( listOf( - EnrolLastBiometricStepResult.FingerprintCaptureResult( + element = EnrolLastBiometricStepResult.FingerprintCaptureResult( REFERENCE_ID, listOf( mockFingerprintResults(Finger.RIGHT_5TH_FINGER), @@ -105,7 +117,9 @@ class BuildSubjectUseCaseTest { ), ), ), + scannedCredential, ), + isAddingCredential = false, ) assertThat(result.fingerprintSamples).isNotEmpty() @@ -115,24 +129,60 @@ class BuildSubjectUseCaseTest { @Test fun `maps first available face capture step results`() { val result = useCase( - createParams( + params = createParams( listOf( EnrolLastBiometricStepResult.FaceMatchResult(emptyList(), mockk()), EnrolLastBiometricStepResult.FaceCaptureResult(REFERENCE_ID, mockFaceResultsList("first")), EnrolLastBiometricStepResult.FaceCaptureResult(REFERENCE_ID, mockFaceResultsList("second")), ), + scannedCredential = scannedCredential, ), + isAddingCredential = false, ) assertThat(result.faceSamples).isNotEmpty() assertThat(result.faceSamples.first().format).isEqualTo("first") } - private fun createParams(steps: List) = EnrolLastBiometricParams( + @Test + fun `includes external credential when isAddingCredential is true and scannedCredential is not null`() { + val mockTokenized = mockk() + val mockCredentialType = mockk() + + val scannedCredential = ScannedCredential( + credential = mockTokenized, + credentialType = mockCredentialType, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = Timestamp(1L), + scanEndTime = Timestamp(1L), + scannedValue = TokenizableString.Raw("test"), + ) + + val result = useCase(createParams(steps = emptyList(), scannedCredential = scannedCredential), isAddingCredential = true) + + assertThat(result.externalCredentials).hasSize(1) + assertThat(result.externalCredentials.first().value).isEqualTo(mockTokenized) + assertThat(result.externalCredentials.first().type).isEqualTo(mockCredentialType) + } + + @Test + fun `has no external credentials when isAddingCredential is true but scannedCredential is null`() { + val result = useCase(createParams(steps = emptyList(), scannedCredential = null), isAddingCredential = true) + + assertThat(result.externalCredentials).isEmpty() + } + + private fun createParams( + steps: List, + scannedCredential: ScannedCredential?, + ) = EnrolLastBiometricParams( projectId = PROJECT_ID, userId = USER_ID, moduleId = MODULE_ID, steps = steps, + scannedCredential = scannedCredential, ) private fun mockFingerprintResults(finger: Finger) = FingerTemplateCaptureResult(finger, byteArrayOf(), 1, "ISO_19794_2") diff --git a/feature/external-credential/.gitignore b/feature/external-credential/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/feature/external-credential/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts new file mode 100644 index 0000000000..411b4dc0e1 --- /dev/null +++ b/feature/external-credential/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("simprints.feature") + id("kotlin-parcelize") +} + +android { + namespace = "com.simprints.feature.externalcredential" +} + +dependencies { + implementation(project(":infra:config-store")) + implementation(project(":infra:config-sync")) + implementation(project(":infra:ui-base")) + implementation(project(":feature:exit-form")) + implementation(project(":infra:enrolment-records:repository")) + implementation(project(":infra:auth-store")) + implementation(project(":infra:matching")) + implementation(project(":infra:events")) + implementation(project(":infra:credential-store")) + implementation(libs.androidX.cameraX.view) + implementation(libs.mlkit.text.recognition) +} diff --git a/feature/external-credential/src/main/AndroidManifest.xml b/feature/external-credential/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e100076157 --- /dev/null +++ b/feature/external-credential/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt new file mode 100644 index 0000000000..2aeb320d87 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt @@ -0,0 +1,28 @@ +package com.simprints.feature.externalcredential + +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.common.FlowType +import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.infra.config.store.models.AgeGroup +import com.simprints.infra.matching.MatchParams + +@ExcludedFromGeneratedTestCoverageReports("Navigation class") +object ExternalCredentialContract { + val DESTINATION = R.id.externalCredentialControllerFragment + + fun getParams( + subjectId: String?, + flowType: FlowType, + ageGroup: AgeGroup?, + probeReferenceId: String? = null, + faceSamples: List = emptyList(), + fingerprintSamples: List = emptyList(), + ) = ExternalCredentialParams( + subjectId = subjectId, + flowType = flowType, + ageGroup = ageGroup, + probeReferenceId = probeReferenceId, + faceSamples = faceSamples, + fingerprintSamples = fingerprintSamples, + ) +} 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 new file mode 100644 index 0000000000..d2584c3ed4 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt @@ -0,0 +1,26 @@ +package com.simprints.feature.externalcredential + +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.feature.externalcredential.model.CredentialMatch +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential + +/** + * 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 +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class ExternalCredentialSearchResult( + val flowType: FlowType, + val scannedCredential: ScannedCredential?, + val matchResults: List, +) : StepResult { + val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful) +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt new file mode 100644 index 0000000000..91e30bc556 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt @@ -0,0 +1,36 @@ +package com.simprints.feature.externalcredential.ext + +import android.content.res.Resources +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.infra.resources.R as IDR + +fun Resources.getQuantityCredentialString( + @StringRes id: Int, + @StringRes specificCredentialRes: Int, + @StringRes multipleCredentialsRes: Int, + credentialTypes: List, +): String { + val documentTypeRes = if (credentialTypes.size == 1) { + specificCredentialRes + } else { + multipleCredentialsRes + } + return getString(id, documentTypeRes) +} + +fun Resources.getCredentialFieldTitle(type: ExternalCredentialType): String = when (type) { + ExternalCredentialType.NHISCard -> IDR.string.mfid_nhis_card_credential_field + ExternalCredentialType.GhanaIdCard -> IDR.string.mfid_ghana_id_credential_field + ExternalCredentialType.QRCode -> IDR.string.mfid_qr_credential_field +}.run(::getString) + +fun Resources.getCredentialTypeString(type: ExternalCredentialType?): String = getCredentialTypeRes(type).run(::getString) + +fun Resources.getCredentialTypeRes(type: ExternalCredentialType?): Int = when (type) { + ExternalCredentialType.NHISCard -> IDR.string.mfid_type_nhis_card + ExternalCredentialType.GhanaIdCard -> IDR.string.mfid_type_ghana_id_card + ExternalCredentialType.QRCode -> IDR.string.mfid_type_qr_code + null -> IDR.string.mfid_type_any_document +} 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 new file mode 100644 index 0000000000..d0d622dba3 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt @@ -0,0 +1,21 @@ +package com.simprints.feature.externalcredential.model + +import android.graphics.Rect +import androidx.annotation.Keep +import java.io.Serializable + +/** + * A serializable substitute for Android's Rect class, which is not serializable. + * Used for passing rectangular bounds as navigation arguments or in any other scenarios where serialization is required + */ +@Keep +data class BoundingBox( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) : Serializable + +fun Rect.toBoundingBox(): BoundingBox = 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/model/CredentialMatch.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/CredentialMatch.kt new file mode 100644 index 0000000000..a628729173 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/CredentialMatch.kt @@ -0,0 +1,20 @@ +package com.simprints.feature.externalcredential.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.FingerprintConfiguration +import com.simprints.infra.matching.MatchResultItem + +@Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class CredentialMatch( + val credential: TokenizableString.Tokenized, + val matchResult: MatchResultItem, + val verificationThreshold: Float, + val faceBioSdk: FaceConfiguration.BioSdk?, + val fingerprintBioSdk: FingerprintConfiguration.BioSdk?, +) { + val isVerificationSuccessful = matchResult.confidence >= verificationThreshold +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt new file mode 100644 index 0000000000..54060a72c9 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt @@ -0,0 +1,19 @@ +package com.simprints.feature.externalcredential.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.step.StepParams +import com.simprints.infra.config.store.models.AgeGroup +import com.simprints.infra.matching.MatchParams + +@Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class ExternalCredentialParams( + val subjectId: String?, + val flowType: FlowType, + val ageGroup: AgeGroup?, + val probeReferenceId: String?, + val faceSamples: List, + val fingerprintSamples: List, +) : StepParams diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt new file mode 100644 index 0000000000..9c6f7dda13 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -0,0 +1,91 @@ +package com.simprints.feature.externalcredential.screens.controller + +import android.os.Bundle +import android.view.View +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import com.simprints.core.livedata.LiveDataEventWithContentObserver +import com.simprints.feature.exitform.ExitFormContract +import com.simprints.feature.exitform.ExitFormResult +import com.simprints.feature.externalcredential.GraphExternalCredentialInternalDirections +import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.infra.uibase.navigation.finishWithResult +import com.simprints.infra.uibase.navigation.handleResult +import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.navigation.navigationParams +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment_external_credential_controller) { + private val params: ExternalCredentialParams by navigationParams() + private val viewModel: ExternalCredentialViewModel by activityViewModels() + + private val hostFragment: Fragment? + get() = childFragmentManager.findFragmentById(R.id.external_credential_host_fragment) + + private val internalNavController: NavController? + get() = hostFragment?.findNavController() + + private val currentlyDisplayedInternalFragment: Fragment? + get() = hostFragment?.childFragmentManager?.fragments?.first() + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + viewModel.init(params) + + findNavController().handleResult( + this, + R.id.externalCredentialControllerFragment, + ExitFormContract.DESTINATION, + ) { + val option = it.submittedOption() + if (option != null) { + findNavController().finishWithResult(this, it) + } else { + internalNavController?.navigateSafely( + currentlyDisplayedInternalFragment, + GraphExternalCredentialInternalDirections.actionGlobalExternalCredentialSelect(), + ) + } + } + internalNavController?.setGraph(R.navigation.graph_external_credential_internal) + + initObservers() + initListeners() + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { + } + viewModel.finishEvent.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver { result -> + findNavController().finishWithResult(this, result) + }, + ) + } + + private fun initListeners() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + when (internalNavController?.currentDestination?.id) { + R.id.externalCredentialSelectFragment, R.id.externalCredentialSearch -> { + // Exit form navigation + findNavController().navigateSafely( + this@ExternalCredentialControllerFragment, + R.id.action_global_refusalFragment, + ) + } + + else -> internalNavController?.popBackStack() + } + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt new file mode 100644 index 0000000000..324ed60dcd --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt @@ -0,0 +1,20 @@ +package com.simprints.feature.externalcredential.screens.controller + +import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.externalcredential.ExternalCredentialType + +internal data class ExternalCredentialState( + val subjectId: String?, + val flowType: FlowType, + val credentialValue: String?, + val selectedType: ExternalCredentialType?, +) { + companion object { + val EMPTY = ExternalCredentialState( + subjectId = null, + flowType = FlowType.VERIFY, + credentialValue = null, + selectedType = null, + ) + } +} 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 new file mode 100644 index 0000000000..d9f7ed06db --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -0,0 +1,115 @@ +package com.simprints.feature.externalcredential.screens.controller + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.core.domain.externalcredential.ExternalCredentialType +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.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class ExternalCredentialViewModel @Inject internal constructor( + private val timeHelper: TimeHelper, + private val configManager: ConfigManager, + private val eventsTracker: ExternalCredentialEventTrackerUseCase, +) : ViewModel() { + private var isInitialized = false + lateinit var params: ExternalCredentialParams + private set + val finishEvent: LiveData> + get() = _finishEvent + private val _finishEvent = MutableLiveData>() + private var state: ExternalCredentialState = ExternalCredentialState.EMPTY + set(value) { + field = value + _stateLiveData.postValue(value) + } + private val _stateLiveData = MutableLiveData(ExternalCredentialState.EMPTY) + val stateLiveData: LiveData = _stateLiveData + + val externalCredentialTypes: LiveData> + get() = _externalCredentialTypes + private val _externalCredentialTypes = MutableLiveData>() + + private lateinit var selectionStartTime: Timestamp + private lateinit var selectionEventId: String + private lateinit var captureStartTime: Timestamp + private var selectedSkipReason: ExternalCredentialSelectionEvent.SkipReason? = null + private var selectedSkipOtherText: String? = null + + init { + viewModelScope.launch { + val config = configManager.getProjectConfiguration() + val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() + _externalCredentialTypes.postValue(allowedExternalCredentials) + } + } + + fun selectionStarted() { + selectionStartTime = timeHelper.now() + } + + fun skipOptionSelected(skipOption: ExternalCredentialSelectionEvent.SkipReason) { + selectedSkipReason = skipOption + } + + fun skipOtherReasonChanged(otherText: String?) { + selectedSkipOtherText = otherText?.ifBlank { null } + } + + private fun updateState(state: (ExternalCredentialState) -> ExternalCredentialState) { + this.state = state(this.state) + } + + fun setSelectedExternalCredentialType(selectedType: ExternalCredentialType?) { + viewModelScope.launch { + if (selectedType != null) { + val selectionEndTime = timeHelper.now() + selectionEventId = eventsTracker.saveSelectionEvent(selectionStartTime, selectionEndTime, selectedType) + captureStartTime = timeHelper.now() + } + updateState { it.copy(selectedType = selectedType) } + } + } + + fun setExternalCredentialValue(value: String) { + updateState { it.copy(credentialValue = value) } + } + + fun init(params: ExternalCredentialParams) { + if (!isInitialized) { + isInitialized = true + this.params = params + updateState { ExternalCredentialState.EMPTY.copy(subjectId = params.subjectId, flowType = params.flowType) } + } + } + + fun finish(result: ExternalCredentialSearchResult) { + viewModelScope.launch { + if (result.scannedCredential == null) { + selectedSkipReason?.let { reason -> + eventsTracker.saveSkippedEvent(selectionStartTime, reason, 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 new file mode 100644 index 0000000000..33957a011c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt @@ -0,0 +1,336 @@ +package com.simprints.feature.externalcredential.screens.scanocr + +import android.Manifest.permission.CAMERA +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.view.View +import android.view.ViewPropertyAnimator +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.ImageAnalysis +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.simprints.core.DispatcherBG +import com.simprints.core.domain.permission.PermissionStatus +import com.simprints.core.livedata.LiveDataEventWithContentObserver +import com.simprints.core.tools.extentions.getCurrentPermissionStatus +import com.simprints.core.tools.extentions.permissionFromResult +import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanOcrBinding +import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +import com.simprints.feature.externalcredential.screens.scanocr.model.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.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.view.applySystemBarInsets +import com.simprints.infra.uibase.view.fadeIn +import com.simprints.infra.uibase.view.fadeOut +import com.simprints.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import javax.inject.Inject +import com.simprints.infra.resources.R as IDR + +@AndroidEntryPoint +internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_external_credential_scan_ocr) { + private val args: ExternalCredentialScanOcrFragmentArgs by navArgs() + private val binding by viewBinding(FragmentExternalCredentialScanOcrBinding::bind) + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() + private val viewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return viewModelFactory.create(args.ocrDocumentType) as T + } + } + } + + private val launchPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val cameraPermissionStatus = requireActivity().permissionFromResult(CAMERA, granted) + previousPermissionStatus = cameraPermissionStatus + if (cameraPermissionStatus == PermissionStatus.Granted) { + initializeFragment() + } else { + val shouldOpenPhoneSettings = cameraPermissionStatus == PermissionStatus.DeniedNeverAskAgain + renderNoPermission(shouldOpenPhoneSettings) + } + } + private var previousPermissionStatus: PermissionStatus? = null + private lateinit var cameraExecutor: ExecutorService + private lateinit var imageAnalysis: ImageAnalysis + private var progressAnimator: ViewPropertyAnimator? = null + private var checkAnimator: ViewPropertyAnimator? = null + private var isAnimatingCompletion: Boolean = false + private var pendingFinishAction: (() -> Unit)? = null + + @Inject + lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory + + @Inject + lateinit var buildOcrCropConfigUseCase: BuildOcrCropConfigUseCase + + @Inject + lateinit var provideCameraListenerUseCase: ProvideCameraListenerUseCase + + @Inject + @DispatcherBG + lateinit var bgDispatcher: CoroutineDispatcher + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + applySystemBarInsets(view) + Simber.i("ExternalCredentialScanOcrFragment started", tag = MULTI_FACTOR_ID) + initObservers() + } + + override fun onResume() { + super.onResume() + val currentPermission = requireActivity().getCurrentPermissionStatus(CAMERA) + when (currentPermission) { + PermissionStatus.Granted -> initializeFragment() + PermissionStatus.Denied -> { + // Permission dialog was already displayed, and user denied permissions. Showing rationale so to avoid constantly-appearing + // system dialog. + if (previousPermissionStatus == currentPermission) { + renderNoPermission(shouldOpenPhoneSettings = false) + } else { + launchPermissionRequest.launch(CAMERA) + } + } + + PermissionStatus.DeniedNeverAskAgain -> { + // Requesting system dialog just in case. Some devices faulty report 'DeniedNeverAskAgain' status when it is actually 'Denied' + launchPermissionRequest.launch(CAMERA) + renderNoPermission(shouldOpenPhoneSettings = true) + } + } + } + + override fun onDestroy() { + stopOcr() + stopCamera() + clearAnimations() + super.onDestroy() + } + + private fun clearAnimations() { + pendingFinishAction = null + isAnimatingCompletion = false + checkAnimator?.cancel() + progressAnimator?.cancel() + } + + private fun initializeFragment() { + renderInitialState() + initCamera(onComplete = { + if (viewModel.isOcrActive) { + startOcr() + } + }) + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is ScanOcrState.ScanningInProgress -> { + renderProgress(state) + if (state.successfulCaptures >= state.scansRequired) { + stopOcr() + viewModel.processOcrResultsAndFinish() + } + } + + ScanOcrState.NotScanning -> renderInitialState() + ScanOcrState.Complete -> animateCompletionState() + } + } + + viewModel.finishOcrEvent.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver { scannedCredential -> + scheduleFinish(scannedCredential) + }, + ) + } + + private fun initCamera(onComplete: () -> Unit) { + if (::cameraExecutor.isInitialized) { + return + } + + cameraExecutor = Executors.newSingleThreadExecutor() + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + val cameraListener = provideCameraListenerUseCase( + cameraProviderFuture = cameraProviderFuture, + surfaceProvider = binding.preview.surfaceProvider, + viewLifecycleOwner = viewLifecycleOwner, + onImageAnalysisReady = { + imageAnalysis = it + onComplete() + }, + ) + cameraProviderFuture.addListener(cameraListener, ContextCompat.getMainExecutor(requireContext())) + } + + private fun renderProgress(state: ScanOcrState.ScanningInProgress) = with(binding) { + val progressPercentage = (state.successfulCaptures * 100 / state.scansRequired).coerceAtMost(100) + buttonScan.isVisible = false + progressContainer.isVisible = true + progressBar.isVisible = true + iconScanComplete.alpha = 0f + progressBar.setProgressCompat(progressPercentage, true) + instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_black)) + viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_white)) + viewfinderMask.alpha = VIEW_FINDER_ALPHA_SCAN_ACTIVE + } + + private fun renderInitialState() = with(binding) { + val documentTypeText = viewModel.getDocumentTypeRes().run(::getString) + permissionRequestView.isVisible = false + instructionsText.isVisible = true + instructionsText.text = getString(IDR.string.mfid_scan_instructions, documentTypeText) + instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white)) + documentScannerArea.isVisible = true + progressContainer.isVisible = false + buttonScan.isVisible = true + buttonScan.setOnClickListener { + viewModel.ocrStarted() + startOcr() + } + viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_black)) + viewfinderMask.alpha = VIEW_FINDER_ALPHA_INITIAL + } + + private fun animateCompletionState() = with(binding) { + isAnimatingCompletion = true + progressBar.fadeOut(FINISH_ANIMATION_DURATION, scaleX = true, fragment = this@ExternalCredentialScanOcrFragment) + scanInstructions.fadeOut(FINISH_ANIMATION_DURATION, scaleX = false, fragment = this@ExternalCredentialScanOcrFragment) + iconScanComplete.fadeIn(FINISH_ANIMATION_DURATION, fragment = this@ExternalCredentialScanOcrFragment, onComplete = { + isAnimatingCompletion = false + // Execute any pending action after the animation. Currently used is for next fragment navigation + pendingFinishAction?.invoke() + pendingFinishAction = null + }) + } + + private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) { + with(binding) { + instructionsText.isVisible = false + progressContainer.isVisible = false + documentScannerArea.isInvisible = true + buttonScan.isVisible = false + val documentTypeText = viewModel.getDocumentTypeRes().run(::getString) + val bodyText = getString(IDR.string.mfid_scan_camera_permission_body, documentTypeText) + if (shouldOpenPhoneSettings) { + permissionRequestView.init( + title = IDR.string.face_capture_permission_denied, + body = bodyText, + buttonText = IDR.string.fingerprint_connect_phone_settings_button, + onClickListener = { + requireActivity().startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${requireActivity().packageName}".toUri(), + ), + ) + }, + ) + } else { + permissionRequestView.init( + title = IDR.string.face_capture_permission_denied, + body = bodyText, + buttonText = IDR.string.face_capture_permission_action, + onClickListener = { + launchPermissionRequest.launch(CAMERA) + }, + ) + } + permissionRequestView.isVisible = true + } + } + + private fun startOcr() { + imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> + if (viewModel.isRunningOcrOnFrame) { + imageProxy.close() + return@setAnalyzer + } + viewModel.ocrOnFrameStarted() + lifecycleScope.launch(bgDispatcher) { + try { + val (bitmap, imageInfo) = imageProxy.toBitmap() to imageProxy.imageInfo + val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase( + rotationDegrees = imageInfo.rotationDegrees, + cameraPreview = binding.preview, + documentScannerArea = binding.documentScannerArea, + ) + viewModel.runOcrOnFrame(frame = bitmap, cropConfig) + } finally { + imageProxy.close() + } + } + } + } + + private fun stopCamera() { + if (::cameraExecutor.isInitialized) { + cameraExecutor.shutdown() + } + } + + private fun stopOcr() { + if (::imageAnalysis.isInitialized) { + imageAnalysis.clearAnalyzer() + } + } + + /** + * Waits until all animations are complete before navigating away. Completion animations are in place because the execution of + * [ExternalCredentialScanOcrViewModel.processOcrResultsAndFinish] is not immediate, and it makes the transition to the next fragment + * smoother for user. + * + * 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) { + val navigationAction = { + findNavController().navigateSafely( + this@ExternalCredentialScanOcrFragment, + ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(credential), + ) + } + if (isAnimatingCompletion) { + pendingFinishAction = navigationAction + } else { + navigationAction.invoke() + } + } + + companion object { + private const val VIEW_FINDER_ALPHA_INITIAL = 0.5f + private const val VIEW_FINDER_ALPHA_SCAN_ACTIVE = 0.9f + private const val FINISH_ANIMATION_DURATION = 300L + } +} 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 new file mode 100644 index 0000000000..72e4f155b1 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt @@ -0,0 +1,173 @@ +package com.simprints.feature.externalcredential.screens.scanocr + +import android.graphics.Bitmap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +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.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.usecase.CropDocumentFromPreviewUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetCredentialCoordinatesUseCase +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.ZoomOntoCredentialUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +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 +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch + +internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( + @Assisted val ocrDocumentType: OcrDocumentType, + private val timeHelper: TimeHelper, + private val normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase, + private val cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase, + private val getCredentialCoordinatesUseCase: GetCredentialCoordinatesUseCase, + private val keepOnlyBestDetectedBlockUseCase: KeepOnlyBestDetectedBlockUseCase, + private val credentialImageRepository: CredentialImageRepository, + private val zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase, + private val tokenizationProcessor: TokenizationProcessor, + private val authStore: AuthStore, + private val configManager: ConfigManager, + @DispatcherBG private val bgDispatcher: CoroutineDispatcher, +) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(ocrDocumentType: OcrDocumentType): ExternalCredentialScanOcrViewModel + } + + private var detectedBlocks: List = emptyList() + var isRunningOcrOnFrame: Boolean = false + private set + val isOcrActive: Boolean + get() = detectedBlocks.isNotEmpty() + private var state: ScanOcrState = ScanOcrState.EMPTY + set(value) { + field = value + _stateLiveData.postValue(value) + } + private val _stateLiveData = MutableLiveData() + val stateLiveData: LiveData = _stateLiveData + val finishOcrEvent: LiveData> + get() = _finishOcrEvent + private val _finishOcrEvent = MutableLiveData>() + + private lateinit var startTime: Timestamp + + private fun updateState(state: (ScanOcrState) -> ScanOcrState) { + this.state = state(this.state) + } + + fun getDocumentTypeRes(): Int = when (ocrDocumentType) { + OcrDocumentType.NhisCard -> R.string.mfid_type_nhis_card + OcrDocumentType.GhanaIdCard -> R.string.mfid_type_ghana_id_card + } + + fun ocrStarted() { + startTime = timeHelper.now() + updateState { + ScanOcrState.ScanningInProgress( + ocrDocumentType = ocrDocumentType, + successfulCaptures = 0, + scansRequired = SUCCESSFUL_SCANS_REQUIRED, + ) + } + } + + fun runOcrOnFrame( + frame: Bitmap, + cropConfig: OcrCropConfig, + ) { + viewModelScope.launch(bgDispatcher) { + try { + Simber.d("started OCR") + val normalizedBitmap = normalizeBitmapToPreviewUseCase(frame, cropConfig) + val cropped = cropDocumentFromPreviewUseCase(bitmap = normalizedBitmap, cutoutRect = cropConfig.cutoutRect) + val detectedBlock = getCredentialCoordinatesUseCase(bitmap = cropped, documentType = ocrDocumentType) ?: return@launch + Simber.d("Detected OCR") + detectedBlocks += detectedBlock + updateState { + ScanOcrState.ScanningInProgress( + ocrDocumentType = ocrDocumentType, + successfulCaptures = detectedBlocks.size, + scansRequired = SUCCESSFUL_SCANS_REQUIRED, + ) + } + } finally { + isRunningOcrOnFrame = false + } + } + } + + fun processOcrResultsAndFinish() { + updateState { ScanOcrState.Complete } + viewModelScope.launch { + val project = configManager.getProject(authStore.signedInProjectId) + 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() + } + } + + 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 ocrOnFrameStarted() { + isRunningOcrOnFrame = true + } + + companion object { + private const val SUCCESSFUL_SCANS_REQUIRED = 5 + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt new file mode 100644 index 0000000000..0c14d4ef8d --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt @@ -0,0 +1,19 @@ +package com.simprints.feature.externalcredential.screens.scanocr + +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType + +internal sealed class ScanOcrState { + data object NotScanning : ScanOcrState() + + data class ScanningInProgress( + val ocrDocumentType: OcrDocumentType, + val successfulCaptures: Int, + val scansRequired: Int, + ) : ScanOcrState() + + data object Complete : ScanOcrState() + + companion object { + val EMPTY = ScanOcrState.NotScanning + } +} 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 new file mode 100644 index 0000000000..8e9f5845ef --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt @@ -0,0 +1,25 @@ +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/OcrCropConfig.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt new file mode 100644 index 0000000000..6a9540665e --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt @@ -0,0 +1,10 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import android.graphics.Rect + +internal data class OcrCropConfig( + val rotationDegrees: Int, + val cutoutRect: Rect, + val previewViewWidth: Int, + val previewViewHeight: Int, +) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt new file mode 100644 index 0000000000..46c6e8cca5 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt @@ -0,0 +1,19 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import com.simprints.core.domain.externalcredential.ExternalCredentialType + +enum class OcrDocumentType { + NhisCard, + GhanaIdCard, +} + +fun ExternalCredentialType.asOcrDocumentType() = when (this) { + ExternalCredentialType.NHISCard -> OcrDocumentType.NhisCard + ExternalCredentialType.GhanaIdCard -> OcrDocumentType.GhanaIdCard + ExternalCredentialType.QRCode -> throw IllegalArgumentException("Cannot create Ocr Document Type from $this") +} + +fun OcrDocumentType.asExternalCredentialType() = when (this) { + OcrDocumentType.NhisCard -> ExternalCredentialType.NHISCard + OcrDocumentType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt new file mode 100644 index 0000000000..50b9970973 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt @@ -0,0 +1,23 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.view.View +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig +import javax.inject.Inject + +internal class BuildOcrCropConfigUseCase @Inject constructor( + private val getBoundsRelativeToParentUseCase: GetBoundsRelativeToParentUseCase, +) { + operator fun invoke( + rotationDegrees: Int, + cameraPreview: View, + documentScannerArea: View, + ): OcrCropConfig { + val cutoutRect = getBoundsRelativeToParentUseCase(parent = cameraPreview, child = documentScannerArea) + return OcrCropConfig( + rotationDegrees = rotationDegrees, + cutoutRect = cutoutRect, + previewViewWidth = cameraPreview.width, + previewViewHeight = cameraPreview.height, + ) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt new file mode 100644 index 0000000000..9f8b0dffb4 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt @@ -0,0 +1,47 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject +import kotlin.math.min + +internal class CalculateLevenshteinDistanceUseCase @Inject constructor() { + /** + * Calculates the Levenshtein distance between two strings. + * + * The Levenshtein distance is the minimum number of single-character edits (insertions, deletions, or substitutions) required to change + * one string into another. + * + * Examples: + * - "kitten" -> "sitting" = 3 (substitute k->s, e->i, insert g) + * - "ABC" -> "ACD" = 1 (substitute B->C) + * - "hello" -> "hello" = 0 (identical strings) + * + * @param s1 first string + * @param s2 second string + * @return minimum number of edits needed to transform s1 into s2 + */ + operator fun invoke( + s1: String, + s2: String, + ): Int { + if (s1 == s2) return 0 + if (s1.isEmpty()) return s2.length + if (s2.isEmpty()) return s1.length + val dp = Array(s1.length + 1) { IntArray(s2.length + 1) } + for (i in 0..s1.length) dp[i][0] = i + for (j in 0..s2.length) dp[0][j] = j + for (i in 1..s1.length) { + for (j in 1..s2.length) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + dp[i][j] = min( + min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + ), + dp[i - 1][j - 1] + cost, + ) + } + } + + return dp[s1.length][s2.length] + } +} 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 new file mode 100644 index 0000000000..5b0f2d4979 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt @@ -0,0 +1,28 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.Rect +import javax.inject.Inject + +internal class CropDocumentFromPreviewUseCase @Inject constructor() { + operator fun invoke( + bitmap: Bitmap, + cutoutRect: Rect, + ): Bitmap { + val left = cutoutRect.left.coerceIn(0, bitmap.width) + val top = cutoutRect.top.coerceIn(0, bitmap.height) + val right = cutoutRect.right.coerceIn(left, bitmap.width) + val bottom = cutoutRect.bottom.coerceIn(top, bitmap.height) + + val width = right - left + val height = bottom - top + + 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 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 new file mode 100644 index 0000000000..ade0e55533 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt @@ -0,0 +1,43 @@ +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/GetBoundsRelativeToParentUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBoundsRelativeToParentUseCase.kt new file mode 100644 index 0000000000..cb3ce092c6 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBoundsRelativeToParentUseCase.kt @@ -0,0 +1,27 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Rect +import android.view.View +import javax.inject.Inject + +internal class GetBoundsRelativeToParentUseCase @Inject constructor() { + operator fun invoke( + parent: View, + child: View, + ): Rect { + val childLocation = IntArray(2) + val parentLocation = IntArray(2) + child.getLocationOnScreen(childLocation) + parent.getLocationOnScreen(parentLocation) + + val offsetX = childLocation[0] - parentLocation[0] + val offsetY = childLocation[1] - parentLocation[1] + + return Rect( + offsetX, + offsetY, + offsetX + child.width, + offsetY + child.height, + ) + } +} 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/GetCredentialCoordinatesUseCase.kt new file mode 100644 index 0000000000..20fe8554a7 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt @@ -0,0 +1,83 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.model.toBoundingBox +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 com.simprints.infra.credential.store.model.CredentialScanImageType.FullDocument +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@ExcludedFromGeneratedTestCoverageReports("Unable to mock Google ML Kit") +internal class GetCredentialCoordinatesUseCase @Inject constructor( + private val ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase, + private val ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase, + private val credentialImageRepository: CredentialImageRepository, +) { + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + + /** + * OCR uses Google ML kit. It has a following hierarchy: + * - Block. A contiguous set of text lines, such as a paragraph or column, + * - Line. A contiguous set of words on the same axis. There can be multiple Lines in the Block + * - Element. A contiguous set of alphanumeric characters ("word") on the same axis. There can be Elements in one Line + * - Symbol. A single alphanumeric character in an Element. + * + * This method returns a [DetectedOcrBlock] class if the OCR managed to find a line that satisfies the given [documentType] pattern. + * If such Line is found, then it is returned in a [DetectedOcrBlock] alongside its parent block, and a normalized value. + * + * Lines are used instead of Elements because the OCR might mistakenly read an extra white space in a Line, resulting in multiple + * Elements. Since Lines are geometrically in one plane, we just take the concatenation of all underlying child Elements, and analyze + * them it as a single string. + * + * @param bitmap bitmap to run OCR on + * @param documentType type of the document + * + * @return [DetectedOcrBlock] if any Line satisfies the [documentType] pattern, or null if none. + */ + suspend operator fun invoke( + bitmap: Bitmap, + documentType: OcrDocumentType, + ): DetectedOcrBlock? { + val image = InputImage.fromBitmap(bitmap, 0) + return try { + val result = Tasks.await(recognizer.process(image)) ?: return null + return result.textBlocks.firstNotNullOfOrNull { textBlock -> + textBlock.lines.firstNotNullOfOrNull { textLine -> + // Getting text from the entire line readout, and normalizing to avoid any extra spaces + val lineReadout = textLine.text.trim().replace(" ", "") + val isValid = when (documentType) { + OcrDocumentType.NhisCard -> ghanaNhisCardOcrSelectorUseCase(lineReadout) + OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrSelectorUseCase(lineReadout) + } + if (isValid) { + val blockBoundingRect = textBlock.boundingBox ?: return@firstNotNullOfOrNull null + val lineBoundingRect = textLine.boundingBox ?: return@firstNotNullOfOrNull null + val savedImagePath = credentialImageRepository.saveCredentialScan(bitmap, imageType = FullDocument) + return@firstNotNullOfOrNull DetectedOcrBlock( + imagePath = savedImagePath, + documentType = documentType, + blockBoundingBox = blockBoundingRect.toBoundingBox(), + lineBoundingBox = lineBoundingRect.toBoundingBox(), + readoutValue = lineReadout, + ) + } else { + return@firstNotNullOfOrNull null + } + } + } + } catch (e: Exception) { + Simber.e("OCR failed for $documentType", e, tag = MULTI_FACTOR_ID) + null + } + } +} 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 new file mode 100644 index 0000000000..6a34597b5a --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt @@ -0,0 +1,50 @@ +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/GhanaIdCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt new file mode 100644 index 0000000000..b5672f17fa --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt @@ -0,0 +1,12 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject + +internal class GhanaIdCardOcrSelectorUseCase @Inject constructor() { + operator fun invoke(readoutValue: String): Boolean = GHANA_ID_PATTERN.matches(readoutValue) + + companion object { + // Ghana ID card number pattern is "GHA-12345789-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/GhanaNhisCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt new file mode 100644 index 0000000000..e7f4133529 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt @@ -0,0 +1,12 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject + +internal class GhanaNhisCardOcrSelectorUseCase @Inject constructor() { + operator fun invoke(readoutValue: String): Boolean = NHIS_PATTERN.matches(readoutValue) + + 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 new file mode 100644 index 0000000000..3f477015a6 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt @@ -0,0 +1,31 @@ +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/NormalizeBitmapToPreviewUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt new file mode 100644 index 0000000000..66154ef8da --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt @@ -0,0 +1,68 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.core.graphics.scale +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig +import javax.inject.Inject + +internal class NormalizeBitmapToPreviewUseCase @Inject constructor() { + /** + * Normalizes a camera capture [inputBitmap] bitmap to match the PreviewView's dimensions and aspect ratio. + * + * This method performs three transformations: + * 1. Rotation - Rotates the bitmap by the specified degrees if needed + * 2. Center cropping - Crops the bitmap to match PreviewView aspect ratio, keeping the center portion + * 3. Scaling - Scales the cropped bitmap to exactly match PreviewView dimensions + * + * The center cropping ensures that the normalized bitmap has the same aspect ratio as what the user + * sees in the camera preview, making OCR results spatially consistent with the preview overlay. + * + * @param inputBitmap the original camera capture bitmap + * @param cropConfig configuration containing rotation, preview width and height + * + * @return normalized bitmap with PreviewView dimensions and aspect ratio + */ + suspend operator fun invoke( + inputBitmap: Bitmap, + cropConfig: OcrCropConfig, + ): Bitmap { + val rotationDegrees = cropConfig.rotationDegrees + val previewViewWidth = cropConfig.previewViewWidth + val previewViewHeight = cropConfig.previewViewHeight + + // Rotate if necessary + val rotated = if (rotationDegrees != 0) { + val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } + Bitmap.createBitmap(inputBitmap, 0, 0, inputBitmap.width, inputBitmap.height, matrix, true) + } else { + inputBitmap + } + + // Center-crop to match PreviewView aspect ratio + val previewRatio = previewViewWidth.toFloat() / previewViewHeight + val inputRatio = rotated.width.toFloat() / rotated.height + + val cropWidth: Int + val cropHeight: Int + val offsetX: Int + val offsetY: Int + + if (inputRatio > previewRatio) { + cropHeight = rotated.height + cropWidth = (cropHeight * previewRatio).toInt() + offsetX = (rotated.width - cropWidth) / 2 + offsetY = 0 + } else { + cropWidth = rotated.width + cropHeight = (cropWidth / previewRatio).toInt() + offsetX = 0 + offsetY = (rotated.height - cropHeight) / 2 + } + + val cropped = Bitmap.createBitmap(rotated, offsetX, offsetY, cropWidth, cropHeight) + + // Scale to PreviewView size + return cropped.scale(previewViewWidth, previewViewHeight) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt new file mode 100644 index 0000000000..6a0e4a9269 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt @@ -0,0 +1,56 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import androidx.camera.core.AspectRatio +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.lifecycle.LifecycleOwner +import com.google.common.util.concurrent.ListenableFuture +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports +import javax.inject.Inject + +@ExcludedFromGeneratedTestCoverageReports("UI Code") +internal class ProvideCameraListenerUseCase @Inject constructor() { + operator fun invoke( + cameraProviderFuture: ListenableFuture, + surfaceProvider: Preview.SurfaceProvider, + viewLifecycleOwner: LifecycleOwner, + onImageAnalysisReady: (ImageAnalysis) -> Unit, + ) = Runnable { + val cameraProvider = cameraProviderFuture.get() + val aspectRatio = AspectRatio.RATIO_16_9 + val preview = Preview + .Builder() + .setTargetAspectRatio(aspectRatio) + .build() + .also { + it.setSurfaceProvider(surfaceProvider) + } + + val imageCapture = ImageCapture + .Builder() + .setTargetAspectRatio(aspectRatio) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + val imageAnalysis = ImageAnalysis + .Builder() + .setTargetAspectRatio(aspectRatio) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis) + onImageAnalysisReady(imageAnalysis) + } catch (e: Exception) { + Simber.e("Camera binding failed in OCR", e, MULTI_FACTOR_ID) + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt new file mode 100644 index 0000000000..ac7c11bea4 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt @@ -0,0 +1,87 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.simprints.feature.externalcredential.model.BoundingBox +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min + +internal class ZoomOntoCredentialUseCase @Inject constructor() { + /** + * Zooms into given image. Zoom area defined by the [boundingBox] + * + * @param imagePath path to image containing the document to zoom into + * @param boundingBox bounding box that defines the zoom area + * @return zoomed-in bitmap + */ + operator fun invoke( + imagePath: String, + boundingBox: BoundingBox, + ): Bitmap { + val bitmap = BitmapFactory.decodeFile(imagePath) + val expandedBox = scaleBoundingBox(boundingBox, BOX_SCALE_FACTOR) + + val left = expandedBox.left.coerceIn(0, bitmap.width) + val top = expandedBox.top.coerceIn(0, bitmap.height) + val right = expandedBox.right.coerceIn(left, bitmap.width) + val bottom = expandedBox.bottom.coerceIn(top, bitmap.height) + val boxWidth = right - left + val boxHeight = bottom - top + + if (boxWidth <= 0 || boxHeight <= 0) { + return bitmap + } + + val boxAspectRatio = boxWidth.toFloat() / boxHeight.toFloat() + val finalWidth: Int + val finalHeight: Int + // Bounding box is taller/wider than 16:10, adding padding to left/right or top/bottom + if (boxAspectRatio > TARGET_ASPECT_RATIO) { + finalWidth = boxWidth + finalHeight = (boxWidth / TARGET_ASPECT_RATIO).toInt() + } else { + finalHeight = boxHeight + finalWidth = (boxHeight * TARGET_ASPECT_RATIO).toInt() + } + + val extraWidth = finalWidth - boxWidth + val extraHeight = finalHeight - boxHeight + val cropRight = min(bitmap.width, right + extraWidth / 2) + val cropBottom = min(bitmap.height, bottom + extraHeight / 2) + + // Adjust if we hit image boundaries + val adjustedLeft = max(0, cropRight - finalWidth) + val adjustedTop = max(0, cropBottom - finalHeight) + val actualWidth = cropRight - adjustedLeft + val actualHeight = cropBottom - adjustedTop + return Bitmap.createBitmap(bitmap, adjustedLeft, adjustedTop, actualWidth, actualHeight) + } + + /** + * Expands a bounding box by a percentage on all sides. + * + * @param box Original bounding box + * @return Expanded bounding box that may exceed image bounds as it is addressed later + */ + private fun scaleBoundingBox( + box: BoundingBox, + scale: Float, + ): BoundingBox { + val boxWidth = box.right - box.left + val boxHeight = box.bottom - box.top + val horizontalExpansion = (boxWidth * (scale - 1f) / 2f).toInt() + val verticalExpansion = (boxHeight * (scale - 1f) / 2f).toInt() + return BoundingBox( + left = box.left - horizontalExpansion, + top = box.top - verticalExpansion, + right = box.right + horizontalExpansion, + bottom = box.bottom + verticalExpansion, + ) + } + + companion object { + private const val TARGET_ASPECT_RATIO = 16f / 10f + private const val BOX_SCALE_FACTOR = 1.15f + } +} 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 new file mode 100644 index 0000000000..13996eebb8 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -0,0 +1,273 @@ +package com.simprints.feature.externalcredential.screens.scanqr + +import android.Manifest.permission.CAMERA +import android.app.Dialog +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.provider.Settings +import android.view.View +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +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 +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.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.camera.qrscan.CameraHelper +import com.simprints.infra.uibase.camera.qrscan.QrCodeAnalyzer +import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.view.applySystemBarInsets +import com.simprints.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject +import com.simprints.infra.resources.R as IDR + +@AndroidEntryPoint +internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_external_credential_scan_qr) { + private val binding by viewBinding(FragmentExternalCredentialScanQrBinding::bind) + private val crashReportTag = MULTI_FACTOR_ID + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() + private val viewModel by viewModels() + + private var dialog: Dialog? = null + private var isCameraInitialized = false + + @Inject + lateinit var cameraHelperFactory: CameraHelper.Factory + private val cameraHelper: CameraHelper by lazy { + cameraHelperFactory.create(crashReportTag) + } + + @Inject + lateinit var qrCodeAnalyzerFactory: QrCodeAnalyzer.Factory + private lateinit var qrCodeAnalyzer: QrCodeAnalyzer + + private val launchPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val cameraPermissionStatus = requireActivity().permissionFromResult(CAMERA, granted) + viewModel.updateCameraPermissionStatus(cameraPermissionStatus) + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + applySystemBarInsets(view) + Simber.i("ExternalCredentialScanQrFragment started", tag = MULTI_FACTOR_ID) + + initObservers() + + if (!requireActivity().hasPermission(CAMERA)) { + launchPermissionRequest.launch(CAMERA) + } + } + + override fun onResume() { + super.onResume() + val cameraPermissionStatus = requireActivity().getCurrentPermissionStatus(CAMERA) + viewModel.updateCameraPermissionStatus(cameraPermissionStatus) + } + + override fun onDestroy() { + dismissDialog() + super.onDestroy() + } + + private fun dismissDialog() { + dialog?.dismiss() + dialog = null + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + ScanQrState.ReadyToScan -> { + renderInitialState() + initCamera() + } + + is ScanQrState.QrCodeCaptured -> renderScanComplete(state) + is ScanQrState.NoCameraPermission -> renderNoPermission(state.shouldOpenPhoneSettings) + } + } + } + + private fun renderInitialState() = with(binding) { + permissionRequestView.isVisible = false + qrInstructionsText.isVisible = true + qrInstructionsText.text = getString(IDR.string.mfid_scan_instructions, getString(IDR.string.mfid_type_qr_code)) + qrPreviewCard.isVisible = false + buttonScan.setText(IDR.string.mfid_qr_scan_no_qr_detected) + buttonScan.isVisible = true + buttonScan.isEnabled = false + buttonScan.setOnClickListener {} + } + + private fun renderScanComplete(state: ScanQrState.QrCodeCaptured) = with(binding) { + val qrCodeRaw = state.qrCode + qrInstructionsText.isVisible = false + qrPreviewCard.isVisible = true + qrPreviewText.text = qrCodeRaw.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, + documentImagePath = null, + credentialBoundingBox = null, + zoomedCredentialImagePath = null, + scanStartTime = state.scanStartTime, + scanEndTime = state.scanEndTime, + scannedValue = state.qrCode, + ) + findNavController().navigateSafely( + this@ExternalCredentialScanQrFragment, + ExternalCredentialScanQrFragmentDirections.actionExternalCredentialSelectScanQrToExternalCredentialSearch(args), + ) + } else { + showInvalidQrCodeFormatDialog( + qrCodeValue = qrCodeRaw, + onDismiss = { + dismissDialog() + viewModel.updateCapturedValue(null) + }, + ) + } + } + } + + private fun showInvalidQrCodeFormatDialog( + qrCodeValue: TokenizableString.Raw, + onDismiss: () -> Unit, + ) { + dismissDialog() + dialog = BottomSheetDialog(requireContext()).also { + val view = layoutInflater + .inflate(R.layout.dialog_qr_wrong_value, null) + .also { view -> + val qrValueTextView = view.findViewById(R.id.qrValue) + val buttonRescan = view.findViewById