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 ef1799cf24..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,7 +122,7 @@ class ClientApiViewModel @Inject internal constructor( sessionId = currentSessionId, enrolledGuid = enrolResponse.guid, subjectActions = coSyncEnrolmentRecords, - externalCredential = enrolResponse.externalCredential, + externalCredential = enrolResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), ), ), ) @@ -140,6 +147,7 @@ class ClientApiViewModel @Inject internal constructor( actionIdentifier = action.actionIdentifier, sessionId = currentSessionId, identifications = identifyResponse.identifications, + isMultiFactorIdEnabled = identifyResponse.isMultiFactorIdEnabled, ), ), ) @@ -162,7 +170,7 @@ class ClientApiViewModel @Inject internal constructor( actionIdentifier = action.actionIdentifier, sessionId = currentSessionId, confirmed = confirmResponse.identificationOutcome, - externalCredential = confirmResponse.externalCredential, + externalCredential = confirmResponse.externalCredential?.toAppExternalCredential(tokenizationProcessor, getProject()), ), ), ) @@ -264,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 ff314dc277..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 @@ -4,9 +4,9 @@ import android.os.Bundle import androidx.core.os.bundleOf import com.simprints.core.DeviceID import com.simprints.core.PackageVersionName -import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.response.AppErrorReason 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,7 @@ 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 @@ -53,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 @@ -152,7 +158,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( actions?.let { putString(Constants.SIMPRINTS_COSYNC_SUBJECT_ACTIONS, it) } } - private fun Bundle.appendExternalCredential(credential: ExternalCredential?) = apply { + private fun Bundle.appendExternalCredential(credential: AppExternalCredential?) = apply { if (credential != null) { val credentialJson = JSONObject() @@ -164,6 +170,21 @@ internal class LibSimprintsResponseMapper @Inject constructor( } } + 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 @@ -204,5 +225,12 @@ internal class LibSimprintsResponseMapper @Inject constructor( 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 cba8da95f5..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 } @@ -52,8 +55,7 @@ internal class GetEnrolmentCreationEventForSubjectUseCase @Inject constructor( moduleId = moduleId, attendantId = attendantId, biometricReferences = EnrolmentRecordCreationEvent.buildBiometricReferences(fingerprintSamples, faceSamples, encoder), - // TODO [CORE-3421] Review if EnrolmentRecordCreationEvent should contain List of external credentials, as it currently doesn't make sense - externalCredentials = externalCredentials + 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 830e788f23..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 @@ -4,6 +4,10 @@ import android.os.Bundle import androidx.arch.core.executor.testing.InstantTaskExecutorRule 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,10 +20,14 @@ 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.* @@ -74,6 +82,9 @@ internal class ClientApiViewModelTest { @MockK lateinit var persistentLogger: PersistentLogger + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var viewModel: ClientApiViewModel @Before @@ -101,6 +112,7 @@ internal class ClientApiViewModelTest { configManager = configManager, timeHelper = timeHelper, persistentLogger = persistentLogger, + tokenizationProcessor = tokenizationProcessor, ) } @@ -162,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 { @@ -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/CommCareResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt index 0ce8ec2849..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 @@ -60,6 +60,7 @@ class CommCareResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) 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 15226739a1..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 @@ -5,7 +5,7 @@ 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.asTokenizableEncrypted +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 @@ -92,6 +92,7 @@ class LibSimprintsResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -119,6 +120,7 @@ class LibSimprintsResponseMapperTest { matchConfidence = AppMatchConfidence.MEDIUM, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -135,7 +137,7 @@ class LibSimprintsResponseMapperTest { @Test fun `correctly maps confirm response`() { - val expectedValue = "expectedValue".asTokenizableEncrypted() + val expectedValue = "expectedValue".asTokenizableRaw() val expectedType = ExternalCredentialType.NHISCard val expectedJson = "{\"$SCANNED_CREDENTIAL_VALUE\":\"$expectedValue\",\"$SCANNED_CREDENTIAL_TYPE\":\"$expectedType\"}" val extras = mapper( @@ -399,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 fd0bd91826..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 @@ -54,6 +54,7 @@ class OdkResponseMapperTest { matchConfidence = AppMatchConfidence.LOW, ), ), + isMultiFactorIdEnabled = false, ), ) @@ -73,6 +74,7 @@ class OdkResponseMapperTest { actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), sessionId = "sessionId", identifications = listOf(), + isMultiFactorIdEnabled = false, ), ) 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 c580af08cb..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 @@ -25,6 +25,7 @@ import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAct 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 @@ -136,21 +137,27 @@ internal class EnrolLastBiometricViewModel @Inject constructor( 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/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 c43ae557c6..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 @@ -155,6 +155,9 @@ class BuildSubjectUseCaseTest { 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) diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index e13f5f8a32..411b4dc0e1 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { 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/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt index fe00b7ee70..91e30bc556 100644 --- 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 @@ -7,22 +7,17 @@ import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.infra.resources.R as IDR fun Resources.getQuantityCredentialString( - @PluralsRes id: Int, + @StringRes id: Int, @StringRes specificCredentialRes: Int, @StringRes multipleCredentialsRes: Int, credentialTypes: List, ): String { - val credentialsAmount = credentialTypes.size - val documentTypeRes = if (credentialsAmount == 1) { + val documentTypeRes = if (credentialTypes.size == 1) { specificCredentialRes } else { multipleCredentialsRes } - return getQuantityString( - id, - credentialsAmount, - getString(documentTypeRes), - ) + return getString(id, documentTypeRes) } fun Resources.getCredentialFieldTitle(type: ExternalCredentialType): String = when (type) { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index d7f60e8678..d9f7ed06db 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -7,17 +7,22 @@ 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 -import kotlin.collections.orEmpty @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 @@ -37,6 +42,12 @@ internal class ExternalCredentialViewModel @Inject internal constructor( 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() @@ -45,12 +56,31 @@ internal class ExternalCredentialViewModel @Inject internal constructor( } } + 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?) { - updateState { it.copy(selectedType = selectedType) } + 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) { @@ -66,6 +96,20 @@ internal class ExternalCredentialViewModel @Inject internal constructor( } fun finish(result: ExternalCredentialSearchResult) { - _finishEvent.send(result) + 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/ExternalCredentialScanOcrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt index 494ea24d1e..72e4f155b1 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt @@ -10,6 +10,8 @@ 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 @@ -37,6 +39,7 @@ 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, @@ -69,6 +72,8 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( get() = _finishOcrEvent private val _finishOcrEvent = MutableLiveData>() + private lateinit var startTime: Timestamp + private fun updateState(state: (ScanOcrState) -> ScanOcrState) { this.state = state(this.state) } @@ -79,6 +84,7 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } fun ocrStarted() { + startTime = timeHelper.now() updateState { ScanOcrState.ScanningInProgress( ocrDocumentType = ocrDocumentType, @@ -121,17 +127,22 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( val credentialType = detectedBlock.documentType.asExternalCredentialType() val blockBoundingBox = detectedBlock.blockBoundingBox val zoomedCredentialImagePath = buildZoomedImagePath(detectedBlock) + val detectedValueRaw = detectedBlock.readoutValue.asTokenizableRaw() val credential = tokenizationProcessor.encrypt( - decrypted = detectedBlock.readoutValue.asTokenizableRaw(), + 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() diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt index 2f02b6e221..13996eebb8 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.getValue import com.simprints.infra.resources.R as IDR @AndroidEntryPoint @@ -144,6 +143,9 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext documentImagePath = null, credentialBoundingBox = null, zoomedCredentialImagePath = null, + scanStartTime = state.scanStartTime, + scanEndTime = state.scanEndTime, + scannedValue = state.qrCode, ) findNavController().navigateSafely( this@ExternalCredentialScanQrFragment, diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt index 033a18bd92..17be0e35f2 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope import com.simprints.core.domain.permission.PermissionStatus import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanqr.usecase.ExternalCredentialQrCodeValidatorUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.TokenKeyType @@ -18,11 +20,13 @@ import javax.inject.Inject @HiltViewModel internal class ExternalCredentialScanQrViewModel @Inject constructor( + private val timeHelper: TimeHelper, private val externalCredentialQrCodeValidator: ExternalCredentialQrCodeValidatorUseCase, private val authStore: AuthStore, private val configManager: ConfigManager, private val tokenizationProcessor: TokenizationProcessor, ) : ViewModel() { + private lateinit var startTime: Timestamp private var state: ScanQrState = ScanQrState.ReadyToScan set(value) { field = value @@ -46,7 +50,12 @@ internal class ExternalCredentialScanQrViewModel @Inject constructor( tokenKeyType = TokenKeyType.ExternalCredential, project = project, ) as TokenizableString.Tokenized - ScanQrState.QrCodeCaptured(qrCode = value.asTokenizableRaw(), qrCodeEncrypted = qrCodeEncrypted) + ScanQrState.QrCodeCaptured( + scanStartTime = startTime, + scanEndTime = timeHelper.now(), + qrCode = value.asTokenizableRaw(), + qrCodeEncrypted = qrCodeEncrypted, + ) } } updateState { newState } @@ -55,7 +64,10 @@ internal class ExternalCredentialScanQrViewModel @Inject constructor( fun updateCameraPermissionStatus(permissionStatus: PermissionStatus) { val newState = when (permissionStatus) { - PermissionStatus.Granted -> ScanQrState.ReadyToScan + PermissionStatus.Granted -> { + startTime = timeHelper.now() // Reset scan timer + ScanQrState.ReadyToScan + } PermissionStatus.Denied -> ScanQrState.NoCameraPermission(shouldOpenPhoneSettings = false) PermissionStatus.DeniedNeverAskAgain -> ScanQrState.NoCameraPermission(shouldOpenPhoneSettings = true) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt index 6ee7decf90..c3f2720756 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ScanQrState.kt @@ -1,6 +1,7 @@ package com.simprints.feature.externalcredential.screens.scanqr import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.Timestamp sealed class ScanQrState { data object ReadyToScan : ScanQrState() @@ -10,6 +11,8 @@ sealed class ScanQrState { ) : ScanQrState() data class QrCodeCaptured( + val scanStartTime: Timestamp, + val scanEndTime: Timestamp, val qrCode: TokenizableString.Raw, val qrCodeEncrypted: TokenizableString.Tokenized, ) : ScanQrState() diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 11f6868b98..9df99a4d9b 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt @@ -202,6 +202,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext viewModel.finish(state) } buttonRecapture.setOnClickListener { + viewModel.trackRecapture() findNavController().navigateSafely( this@ExternalCredentialSearchFragment, ExternalCredentialSearchFragmentDirections.actionExternalCredentialSearchToExternalCredentialSelectFragment(), diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt index 8a704840af..d13785ac91 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt @@ -10,18 +10,21 @@ import com.simprints.core.domain.externalcredential.ExternalCredentialType 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.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState import com.simprints.feature.externalcredential.screens.search.usecase.MatchCandidatesUseCase +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase 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.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationResult import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,11 +34,13 @@ import com.simprints.infra.resources.R as IDR internal class ExternalCredentialSearchViewModel @AssistedInject constructor( @Assisted val scannedCredential: ScannedCredential, @Assisted val externalCredentialParams: ExternalCredentialParams, + private val timeHelper: TimeHelper, private val authStore: AuthStore, private val configManager: ConfigManager, private val matchCandidatesUseCase: MatchCandidatesUseCase, private val tokenizationProcessor: TokenizationProcessor, private val enrolmentRecordRepository: EnrolmentRecordRepository, + private val eventsTracker: ExternalCredentialEventTrackerUseCase, ) : ViewModel() { @AssistedFactory interface Factory { @@ -57,6 +62,8 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( private val _stateLiveData = MutableLiveData(state) val stateLiveData: LiveData = _stateLiveData + private val confirmationStartTime = timeHelper.now() + private fun updateState(state: (SearchCredentialState) -> SearchCredentialState) { this.state = state(this.state) } @@ -129,11 +136,19 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( updateState { it.copy(searchState = SearchState.Searching) } val project = configManager.getProject(authStore.signedInProjectId) val candidates = enrolmentRecordRepository.load(SubjectQuery(projectId = project.id, externalCredential = credential)) + + val startTime = timeHelper.now() when { - candidates.isEmpty() -> updateState { it.copy(searchState = SearchState.CredentialNotFound) } + candidates.isEmpty() -> { + eventsTracker.saveSearchEvent(startTime, scannedCredential.credentialScanId, emptyList()) + updateState { it.copy(searchState = SearchState.CredentialNotFound) } + } + else -> { val projectConfig = configManager.getProjectConfiguration() val matches = matchCandidatesUseCase(candidates, credential, externalCredentialParams, project, projectConfig) + eventsTracker.saveSearchEvent(startTime, scannedCredential.credentialScanId, candidates) + updateState { state -> state.copy(searchState = SearchState.CredentialLinked(matchResults = matches)) } } } @@ -149,20 +164,35 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( ExternalCredentialType.QRCode -> InputType.TYPE_CLASS_TEXT } + fun trackRecapture() { + viewModelScope.launch { + eventsTracker.saveConfirmation( + confirmationStartTime, + ExternalCredentialConfirmationResult.RECAPTURE, + ) + } + } + fun finish(state: SearchCredentialState) { - val matches = when (val searchState = state.searchState) { - SearchState.Searching, - SearchState.CredentialNotFound, - -> emptyList() + viewModelScope.launch { + eventsTracker.saveConfirmation( + confirmationStartTime, + ExternalCredentialConfirmationResult.CONTINUE, + ) + val matches = when (val searchState = state.searchState) { + SearchState.Searching, + SearchState.CredentialNotFound, + -> emptyList() - is SearchState.CredentialLinked -> searchState.matchResults + is SearchState.CredentialLinked -> searchState.matchResults + } + _finishEvent.send( + ExternalCredentialSearchResult( + flowType = externalCredentialParams.flowType, + scannedCredential = state.scannedCredential, + matchResults = matches, + ), + ) } - _finishEvent.send( - ExternalCredentialSearchResult( - flowType = externalCredentialParams.flowType, - scannedCredential = state.scannedCredential, - matchResults = matches, - ), - ) } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt index 005f1e90fd..134340f857 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt @@ -4,22 +4,26 @@ import androidx.annotation.Keep 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.tools.time.Timestamp +import com.simprints.core.tools.utils.randomUUID import com.simprints.feature.externalcredential.model.BoundingBox import java.io.Serializable -import java.util.UUID -import kotlin.toString @Keep data class ScannedCredential( + val credentialScanId: String = randomUUID(), val credential: TokenizableString.Tokenized, val credentialType: ExternalCredentialType, val documentImagePath: String?, val zoomedCredentialImagePath: String?, val credentialBoundingBox: BoundingBox?, + val scanStartTime: Timestamp, + val scanEndTime: Timestamp, + val scannedValue: TokenizableString.Raw, ) : Serializable fun ScannedCredential.toExternalCredential(subjectId: String) = ExternalCredential( - id = UUID.randomUUID().toString(), + id = credentialScanId, value = credential, subjectId = subjectId, type = credentialType, diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt index 6ab288a498..630034402c 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt @@ -7,6 +7,7 @@ import android.widget.Button import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -74,7 +75,7 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext private fun initViews(types: List) { binding.title.text = resources.getQuantityCredentialString( - id = IDR.plurals.mfid_scan_action, + id = IDR.string.mfid_scan_action, specificCredentialRes = resources.getCredentialTypeRes(types.firstOrNull()), multipleCredentialsRes = IDR.string.mfid_type_any_document, credentialTypes = types, @@ -87,6 +88,7 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext fillRecyclerView(externalCredentialTypes) initViews(externalCredentialTypes) initListeners(externalCredentialTypes) + mainViewModel.selectionStarted() } } @@ -136,7 +138,7 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext val confirmButton = view.findViewById