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 index 3fd619dac9..ce9d9c8636 100644 --- 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 @@ -13,9 +13,11 @@ import com.simprints.infra.matching.MatchResultItem data class CredentialMatch( val credential: TokenizableString.Tokenized, val matchResult: MatchResultItem, + val probeReferenceId: String?, val verificationThreshold: Float, val faceBioSdk: FaceConfiguration.BioSdk?, val fingerprintBioSdk: FingerprintConfiguration.BioSdk?, ) : StepResult { val isVerificationSuccessful = matchResult.confidence >= verificationThreshold + val isFaceMatch = faceBioSdk != null } 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 index 9c6f7dda13..79a667b186 100644 --- 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 @@ -13,6 +13,7 @@ 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.feature.externalcredential.screens.select.ExternalCredentialSelectFragmentDirections import com.simprints.infra.uibase.navigation.finishWithResult import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.navigateSafely @@ -76,15 +77,13 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment 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, - ) + R.id.externalCredentialSearch -> { + internalNavController?.navigate(R.id.externalCredentialSkip) } - else -> internalNavController?.popBackStack() + R.id.externalCredentialSkip, R.id.externalCredentialScanQr, R.id.externalCredentialScanOcr -> { + internalNavController?.popBackStack() + } } } } 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 d13785ac91..e0c2289fc2 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 @@ -104,9 +104,14 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( searchState: SearchState, flowType: FlowType, ): Int? = when (searchState) { - SearchState.Searching -> null // button is not displayed during search + // button is not displayed during search + SearchState.Searching -> null + is SearchState.CredentialLinked -> when (flowType) { - FlowType.ENROL -> IDR.string.mfid_action_enrol_anyway + FlowType.ENROL -> { + IDR.string.mfid_action_enrol_anyway + } + else -> { if (searchState.hasSuccessfulVerifications) { IDR.string.mfid_action_go_to_record @@ -135,19 +140,18 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( private suspend fun searchSubjectsLinkedToCredential(credential: TokenizableString.Tokenized) { updateState { it.copy(searchState = SearchState.Searching) } val project = configManager.getProject(authStore.signedInProjectId) + val searchStartTime = timeHelper.now() val candidates = enrolmentRecordRepository.load(SubjectQuery(projectId = project.id, externalCredential = credential)) + eventsTracker.saveSearchEvent(searchStartTime, scannedCredential.credentialScanId, candidates) - val startTime = timeHelper.now() when { 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)) } } @@ -159,8 +163,11 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( * alpha-numeric, while the NHIS card memberships contain only digits. */ fun getKeyBoardInputType() = when (scannedCredential.credentialType) { - ExternalCredentialType.NHISCard -> InputType.TYPE_CLASS_NUMBER // NHIS card membership contains only numbers + // NHIS card membership contains only numbers + ExternalCredentialType.NHISCard -> InputType.TYPE_CLASS_NUMBER + ExternalCredentialType.GhanaIdCard -> InputType.TYPE_CLASS_TEXT + ExternalCredentialType.QRCode -> InputType.TYPE_CLASS_TEXT } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCase.kt index b63d6e6d79..f0fdb9c070 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCase.kt @@ -1,8 +1,10 @@ package com.simprints.feature.externalcredential.screens.search.usecase import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.enrolment.records.repository.domain.models.Subject @@ -16,6 +18,8 @@ internal class MatchCandidatesUseCase @Inject constructor( private val createMatchParamsUseCase: CreateMatchParamsUseCase, private val faceMatcher: FaceMatcherUseCase, private val fingerprintMatcher: FingerprintMatcherUseCase, + private val eventsTracker: ExternalCredentialEventTrackerUseCase, + private val timeHelper: TimeHelper, ) { suspend operator fun invoke( candidates: List, @@ -24,10 +28,11 @@ internal class MatchCandidatesUseCase @Inject constructor( project: Project, projectConfig: ProjectConfiguration, ): List = candidates.flatMap { candidate -> + val probeReferenceId = externalCredentialParams.probeReferenceId val matchParams = createMatchParamsUseCase( candidateSubjectId = candidate.subjectId, flowType = externalCredentialParams.flowType, - probeReferenceId = externalCredentialParams.probeReferenceId, + probeReferenceId = probeReferenceId, projectConfiguration = projectConfig, faceSamples = externalCredentialParams.faceSamples, fingerprintSamples = externalCredentialParams.fingerprintSamples, @@ -39,17 +44,21 @@ internal class MatchCandidatesUseCase @Inject constructor( matchParams.probeFaceSamples.isNotEmpty() -> { val faceSdk = matchParams.faceSDK ?: return@mapNotNull null projectConfig.face?.getSdkConfiguration(faceSdk)?.verificationMatchThreshold?.let { matchThreshold -> + val startTime = timeHelper.now() (faceMatcher(matchParams, project).last() as? MatcherState.Success) ?.matchResultItems .orEmpty() .map { result -> - CredentialMatch( + val match = CredentialMatch( credential = credential, matchResult = result, + probeReferenceId = probeReferenceId, verificationThreshold = matchThreshold, faceBioSdk = faceSdk, fingerprintBioSdk = null, ) + eventsTracker.saveMatchEvent(startTime, match) + return@map match } } } @@ -57,17 +66,21 @@ internal class MatchCandidatesUseCase @Inject constructor( else -> { val fingerprintSdk = matchParams.fingerprintSDK ?: return@mapNotNull null projectConfig.fingerprint?.getSdkConfiguration(fingerprintSdk)?.verificationMatchThreshold?.let { matchThreshold -> + val startTime = timeHelper.now() (fingerprintMatcher(matchParams, project).last() as? MatcherState.Success) ?.matchResultItems .orEmpty() .map { result -> - CredentialMatch( + val match = CredentialMatch( credential = credential, matchResult = result, + probeReferenceId = probeReferenceId, verificationThreshold = matchThreshold, faceBioSdk = null, fingerprintBioSdk = fingerprintSdk, ) + eventsTracker.saveMatchEvent(startTime, match) + return@map match } } } 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 630034402c..7e942024e0 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 @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.View import android.widget.Button import android.widget.TextView +import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -71,6 +72,9 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext onCancel = ::dismissDialog, ) } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + binding.skipScanning.performClick() + } } private fun initViews(types: List) { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt index cb936b120c..415bf8c944 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt @@ -4,10 +4,12 @@ import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.screens.scanocr.usecase.CalculateLevenshteinDistanceUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.externalcredential.screens.search.model.toExternalCredential import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.FingerprintConfiguration import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager @@ -18,9 +20,13 @@ import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirma import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationResult import com.simprints.infra.events.event.domain.models.ExternalCredentialSearchEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent +import com.simprints.infra.events.event.domain.models.FingerComparisonStrategy +import com.simprints.infra.events.event.domain.models.MatchEntry +import com.simprints.infra.events.event.domain.models.OneToOneMatchEvent import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.logging.Simber import javax.inject.Inject +import com.simprints.infra.config.store.models.FingerprintConfiguration.FingerComparisonStrategy as ConfigFingerComparisonStrategy internal class ExternalCredentialEventTrackerUseCase @Inject constructor( private val timeHelper: TimeHelper, @@ -30,6 +36,25 @@ internal class ExternalCredentialEventTrackerUseCase @Inject constructor( private val eventRepository: SessionEventRepository, private val calculateDistance: CalculateLevenshteinDistanceUseCase, ) { + suspend fun saveMatchEvent( + startTime: Timestamp, + match: CredentialMatch, + ) { + eventRepository.addOrUpdateEvent( + OneToOneMatchEvent( + createdAt = startTime, + endTime = timeHelper.now(), + candidateId = match.matchResult.subjectId, + matcher = match.faceBioSdk?.name ?: match.fingerprintBioSdk?.name!!, + result = with(match.matchResult) { + MatchEntry(subjectId, confidence) + }, + fingerComparisonStrategy = if (match.isFaceMatch) null else getFingerprintComparisonStrategy(match.fingerprintBioSdk!!), + probeBiometricReferenceId = match.probeReferenceId.orEmpty(), + ), + ) + } + suspend fun saveSearchEvent( startTime: Timestamp, externalCredentialId: String, @@ -130,6 +155,18 @@ internal class ExternalCredentialEventTrackerUseCase @Inject constructor( ) } + private suspend fun getFingerprintComparisonStrategy(bioSdk: FingerprintConfiguration.BioSdk) = configManager + .getProjectConfiguration() + .fingerprint + ?.getSdkConfiguration(bioSdk) + ?.comparisonStrategyForVerification + ?.let { + when (it) { + ConfigFingerComparisonStrategy.SAME_FINGER -> FingerComparisonStrategy.SAME_FINGER + ConfigFingerComparisonStrategy.CROSS_FINGER_USING_MEAN_OF_MAX -> FingerComparisonStrategy.CROSS_FINGER_USING_MEAN_OF_MAX + } + } + companion object Companion { private const val NHIS_CARD_ID_LENGTH = 8 private const val GHANA_ID_CARD_ID_LENGTH = 15 diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCaseTest.kt index b7e07b2fe1..f1b8897a50 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/usecase/MatchCandidatesUseCaseTest.kt @@ -3,7 +3,9 @@ package com.simprints.feature.externalcredential.screens.search.usecase import com.google.common.truth.Truth.* import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.store.models.AgeGroup import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.FingerprintConfiguration @@ -76,6 +78,11 @@ internal class MatchCandidatesUseCaseTest { @MockK private lateinit var matcherSuccess: MatcherState.Success + @MockK + private lateinit var timeHelper: TimeHelper + + @MockK + lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase private val credential = "credential".asTokenizableEncrypted() private val subjectId = "subjectId" private val probeReferenceId = "probeReferenceId" @@ -88,6 +95,8 @@ internal class MatchCandidatesUseCaseTest { createMatchParamsUseCase = createMatchParamsUseCase, faceMatcher = faceMatcher, fingerprintMatcher = fingerprintMatcher, + timeHelper = timeHelper, + eventsTracker = eventsTracker, ) every { subject.subjectId } returns subjectId @@ -150,6 +159,8 @@ internal class MatchCandidatesUseCaseTest { assertThat(result[0].verificationThreshold).isEqualTo(verificationMatchThreshold) assertThat(result[0].faceBioSdk).isEqualTo(FaceConfiguration.BioSdk.RANK_ONE) assertThat(result[0].fingerprintBioSdk).isNull() + assertThat(result[0].probeReferenceId).isEqualTo("probeReferenceId") + coVerify { eventsTracker.saveMatchEvent(any(), any()) } } @Test @@ -169,6 +180,8 @@ internal class MatchCandidatesUseCaseTest { assertThat(result[0].verificationThreshold).isEqualTo(verificationMatchThreshold) assertThat(result[0].faceBioSdk).isNull() assertThat(result[0].fingerprintBioSdk).isEqualTo(FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER) + assertThat(result[0].probeReferenceId).isEqualTo("probeReferenceId") + coVerify { eventsTracker.saveMatchEvent(any(), any()) } } @Test diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt index e006fdcfcb..f35681013b 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt @@ -6,9 +6,13 @@ import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.screens.scanocr.usecase.CalculateLevenshteinDistanceUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.FingerprintConfiguration +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 @@ -18,6 +22,8 @@ import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirma import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationResult import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent.SkipReason +import com.simprints.infra.events.event.domain.models.FingerComparisonStrategy +import com.simprints.infra.events.event.domain.models.OneToOneMatchEvent import com.simprints.infra.events.session.SessionEventRepository import io.mockk.* import io.mockk.impl.annotations.MockK @@ -167,6 +173,49 @@ class ExternalCredentialEventTrackerUseCaseTest { } } + @Test + fun `saveMatchEvent should save match event for face SDK`() = runTest { + val match = makeCredentialMatch( + faceSdk = FACE_SDK, + fingerprintSdk = null, + ) + + useCase.saveMatchEvent(START_TIME, match) + + verifyMatchEvent( + expectedMatcher = FACE_SDK.name, + expectedFingerStrategy = null, + ) + } + + @Test + fun `saveMatchEvent should save match event for fingerprint SDK`() = runTest { + val fingerprintConfig = mockk { + every { comparisonStrategyForVerification } returns + FingerprintConfiguration.FingerComparisonStrategy.SAME_FINGER + } + + val projectConfig = mockk { + every { fingerprint } returns mockk { + every { getSdkConfiguration(FINGERPRINT_SDK) } returns fingerprintConfig + } + } + + coEvery { configManager.getProjectConfiguration() } returns projectConfig + + val match = makeCredentialMatch( + faceSdk = null, + fingerprintSdk = FINGERPRINT_SDK, + ) + + useCase.saveMatchEvent(START_TIME, match) + + verifyMatchEvent( + expectedMatcher = FINGERPRINT_SDK.name, + expectedFingerStrategy = FingerComparisonStrategy.SAME_FINGER, + ) + } + private fun makeScannedCredential(type: ExternalCredentialType) = ScannedCredential( credentialScanId = "test-scan-id", credential = RAW_SCANNED_VALUE.asTokenizableEncrypted(), @@ -179,6 +228,43 @@ class ExternalCredentialEventTrackerUseCaseTest { scannedValue = RAW_SCANNED_VALUE.asTokenizableRaw(), ) + private fun makeCredentialMatch( + faceSdk: FaceConfiguration.BioSdk?, + fingerprintSdk: FingerprintConfiguration.BioSdk?, + ): CredentialMatch { + val matchResult = mockk { + every { subjectId } returns SUBJECT_ID + every { confidence } returns CONFIDENCE + } + + return CredentialMatch( + credential = RAW_SCANNED_VALUE.asTokenizableEncrypted(), + matchResult = matchResult, + probeReferenceId = PROBE_REFERENCE_ID, + verificationThreshold = 0.5f, + faceBioSdk = faceSdk, + fingerprintBioSdk = fingerprintSdk, + ) + } + + private fun verifyMatchEvent( + expectedMatcher: String, + expectedFingerStrategy: FingerComparisonStrategy?, + ) { + val slot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(slot)) } + + with(slot.captured.payload as OneToOneMatchEvent.OneToOneMatchPayload.OneToOneMatchPayloadV4) { + assertThat(createdAt).isEqualTo(START_TIME) + assertThat(endedAt).isEqualTo(END_TIME) + assertThat(candidateId).isEqualTo(SUBJECT_ID) + assertThat(matcher).isEqualTo(expectedMatcher) + assertThat(result?.score).isEqualTo(CONFIDENCE) + assertThat(fingerComparisonStrategy).isEqualTo(expectedFingerStrategy) + assertThat(probeBiometricReferenceId).isEqualTo(PROBE_REFERENCE_ID) + } + } + companion object Companion { private val START_TIME = Timestamp(0L) private val SCAN_START_TIME = Timestamp(3L) @@ -189,5 +275,9 @@ class ExternalCredentialEventTrackerUseCaseTest { private const val RAW_SCANNED_VALUE = "scanned-value" private const val DEFAULT_DISTANCE = 7 private const val SELECTION_ID = "selection_id" + private const val CONFIDENCE = 0.9f + private const val PROBE_REFERENCE_ID = "probe-ref-id" + private val FACE_SDK = FaceConfiguration.BioSdk.RANK_ONE + private val FINGERPRINT_SDK = FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER } } diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt index 8e80443f5b..62e759acf9 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt @@ -1,6 +1,5 @@ package com.simprints.feature.orchestrator.usecases.response -import com.simprints.core.domain.response.AppMatchConfidence import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.infra.config.store.models.DecisionPolicy import com.simprints.infra.config.store.models.ProjectConfiguration @@ -13,9 +12,6 @@ import com.simprints.infra.orchestration.data.responses.AppMatchResult import com.simprints.infra.orchestration.data.responses.AppResponse import java.io.Serializable import javax.inject.Inject -import kotlin.collections.ifEmpty -import kotlin.collections.map -import kotlin.collections.take internal class CreateIdentifyResponseUseCase @Inject constructor( private val eventRepository: SessionEventRepository, @@ -25,26 +21,34 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( results: List, ): AppResponse { val isMultiFactorIdEnabled = projectConfiguration.multifactorId?.allowedExternalCredentials?.isNotEmpty() ?: false - val credentialFaceMatchResults = credentialResultsMapper(results, projectConfiguration, isFace = true) - val credentialFingerprintMatchResults = credentialResultsMapper(results, projectConfiguration, isFace = false) - val currentSessionId = eventRepository.getCurrentSessionScope().id - val faceResults = credentialFaceMatchResults + getFaceMatchResults(results, projectConfiguration) - val bestFaceConfidence = faceResults.firstOrNull()?.confidenceScore ?: 0 + val faceMatchResults = getFaceMatchResults(results, projectConfiguration) + val bestFaceConfidence = faceMatchResults.firstOrNull()?.confidenceScore ?: 0 + + val fingerprintMatchResults = getFingerprintResults(results, projectConfiguration) + val bestFingerprintConfidence = fingerprintMatchResults.firstOrNull()?.confidenceScore ?: 0 - val fingerprintResults = credentialFingerprintMatchResults + getFingerprintResults(results, projectConfiguration) - val bestFingerprintConfidence = fingerprintResults.firstOrNull()?.confidenceScore ?: 0 + val isUsingFingerprintResults = bestFingerprintConfidence > bestFaceConfidence + val bestMatcherIdentifications = if (isUsingFingerprintResults) { + fingerprintMatchResults + } else { + faceMatchResults + } + val allCredentialResults = ( + credentialResultsMapper(results, projectConfiguration, isFace = true) + + credentialResultsMapper(results, projectConfiguration, isFace = false) + ).sortedByDescending(AppMatchResult::confidenceScore) + + // Return the results with the credential results on top, followed by highest confidence score 1:N match results + val identifications = (allCredentialResults + bestMatcherIdentifications) + .distinctBy(AppMatchResult::guid) + .take(projectConfiguration.identification.maxNbOfReturnedCandidates) return AppIdentifyResponse( sessionId = currentSessionId, isMultiFactorIdEnabled = isMultiFactorIdEnabled, - // Return the results with the highest confidence score - identifications = if (bestFingerprintConfidence > bestFaceConfidence) { - fingerprintResults.distinctBy(AppMatchResult::guid) - } else { - faceResults.distinctBy(AppMatchResult::guid) - }, + identifications = identifications, ) } @@ -58,7 +62,6 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( ?.let { fingerprintDecisionPolicy -> fingerprintMatchResult.results.mapToMatchResults( decisionPolicy = fingerprintDecisionPolicy, - projectConfiguration = projectConfiguration, isCredentialMatch = false, verificationMatchThreshold = null, ) @@ -75,7 +78,6 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( ?.let { faceDecisionPolicy -> faceMatchResult.results.mapToMatchResults( decisionPolicy = faceDecisionPolicy, - projectConfiguration = projectConfiguration, isCredentialMatch = false, verificationMatchThreshold = null, ) @@ -85,17 +87,23 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( private fun List.mapToMatchResults( decisionPolicy: DecisionPolicy, verificationMatchThreshold: Float?, - projectConfiguration: ProjectConfiguration, isCredentialMatch: Boolean, ): List { - val goodResults = this - .filter { it.confidence >= decisionPolicy.low } - .sortedByDescending { it.confidence } - // Attempt to include only high confidence matches - return goodResults - .filter { it.confidence >= decisionPolicy.high } - .ifEmpty { goodResults } - .take(projectConfiguration.identification.maxNbOfReturnedCandidates) + val results = if (isCredentialMatch) { + // Credential matches are returned regardless of confidence score + this + } else { + // Attempt to include only high confidence matches. + this + .filter { it.confidence >= decisionPolicy.low } + .sortedByDescending { it.confidence } + .let { goodResults -> + goodResults + .filter { it.confidence >= decisionPolicy.high } + .ifEmpty { goodResults } + } + } + return results .map { AppMatchResult( guid = it.subjectId, @@ -142,9 +150,8 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( return@let matches .mapToMatchResults( decisionPolicy = decisionPolicy, - projectConfiguration = projectConfiguration, isCredentialMatch = true, verificationMatchThreshold = verificationMatchThreshold, - ).sortedByDescending(AppMatchResult::confidenceScore) + ) }.orEmpty() } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt index e450f188d0..6f3e90454e 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/cache/OrchestratorCacheIntegrationTest.kt @@ -230,6 +230,7 @@ class OrchestratorCacheIntegrationTest { verificationThreshold = 55f, faceBioSdk = FaceConfiguration.BioSdk.RANK_ONE, fingerprintBioSdk = FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER, + probeReferenceId = "probeReferenceId", ), ), ), diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt index 22d51a8680..ce45e0c6bd 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt @@ -39,6 +39,7 @@ class CreateIdentifyResponseUseCaseTest { fun `Returns no identifications if no decision policy`() = runTest { val result = useCase( mockk { + every { identification.maxNbOfReturnedCandidates } returns 2 every { multifactorId?.allowedExternalCredentials } returns null every { face?.getSdkConfiguration((any()))?.decisionPolicy } returns null every { fingerprint?.getSdkConfiguration((any()))?.decisionPolicy } returns null @@ -204,77 +205,15 @@ class CreateIdentifyResponseUseCaseTest { } @Test - fun `Returns only face credential results sorted by confidence descending`() = runTest { - val (faceSmallConfidence, smallConfidence) = "faceSmallConfidence" to 50f - val (faceBigConfidence, bigConfidence) = "faceBigConfidence" to 99f - val faceMatches = listOf( - mockk { - every { verificationThreshold } returns 0.0f - every { matchResult } returns FaceMatchResult.Item( - subjectId = faceSmallConfidence, - confidence = smallConfidence, - ) - every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE - every { fingerprintBioSdk } returns null - }, - mockk { - every { matchResult } returns FaceMatchResult.Item( - subjectId = faceBigConfidence, - confidence = bigConfidence, - ) - every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE - every { fingerprintBioSdk } returns null - }, - ) - - val fingerprintMatches = listOf( - mockk { - every { matchResult } returns FingerprintMatchResult.Item( - subjectId = "fingerprintSubjectId", - confidence = 90f, - ) - every { faceBioSdk } returns null - every { fingerprintBioSdk } returns FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER - }, - ) - - val result = useCase( - mockk { - every { multifactorId?.allowedExternalCredentials } returns null - every { identification.maxNbOfReturnedCandidates } returns 5 - every { face?.getSdkConfiguration(FaceConfiguration.BioSdk.RANK_ONE)?.decisionPolicy } returns DecisionPolicy(20, 50, 100) - every { face?.getSdkConfiguration(FaceConfiguration.BioSdk.RANK_ONE)?.verificationMatchThreshold } returns 0.0f - every { fingerprint?.getSdkConfiguration(FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER)?.decisionPolicy } returns - DecisionPolicy(20, 50, 100) - every { - fingerprint - ?.getSdkConfiguration( - FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER, - )?.verificationMatchThreshold - } returns - 0.0f - }, - results = listOf( - mockk { - every { matchResults } returns faceMatches + fingerprintMatches - }, - ), - ) - - assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() - assertThat(result.identifications.map { it.guid }).isEqualTo(listOf(faceBigConfidence, faceSmallConfidence)) - assertThat(result.identifications.map { it.confidenceScore }).isEqualTo(listOf(bigConfidence.toInt(), smallConfidence.toInt())) - } - - @Test - fun `Returns only fingerprint credential results sorted by confidence descending`() = runTest { - val (fingerprintSmallConfidence, smallConfidence) = "fingerprintSmallConfidence" to 50f - val (fingerprintBigConfidence, bigConfidence) = "fingerprintBigConfidence" to 99f + fun `Returns both fingerprint and face credential results sorted by confidence descending`() = runTest { + val (fingerprintSmallConfidenceGUID, smallConfidence) = "fingerprintSmallConfidenceGUID" to 50f + val (fingerprintBigConfidenceGUID, fingerprintBigConfidence) = "fingerprintBigConfidenceGUID" to 99f + val (faceBigConfidenceGUID, faceBigConfidence) = "faceBigConfidenceGUID" to fingerprintBigConfidence - 1 val fingerprintMatches = listOf( mockk { every { verificationThreshold } returns 0.0f every { matchResult } returns FingerprintMatchResult.Item( - subjectId = fingerprintSmallConfidence, + subjectId = fingerprintSmallConfidenceGUID, confidence = smallConfidence, ) every { faceBioSdk } returns null @@ -282,8 +221,8 @@ class CreateIdentifyResponseUseCaseTest { }, mockk { every { matchResult } returns FingerprintMatchResult.Item( - subjectId = fingerprintBigConfidence, - confidence = bigConfidence, + subjectId = fingerprintBigConfidenceGUID, + confidence = fingerprintBigConfidence, ) every { faceBioSdk } returns null every { fingerprintBioSdk } returns FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER @@ -293,8 +232,8 @@ class CreateIdentifyResponseUseCaseTest { val faceMatches = listOf( mockk { every { matchResult } returns FaceMatchResult.Item( - subjectId = "faceSubjectId", - confidence = 90f, + subjectId = faceBigConfidenceGUID, + confidence = faceBigConfidence, ) every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE every { fingerprintBioSdk } returns null @@ -325,8 +264,16 @@ class CreateIdentifyResponseUseCaseTest { ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() - assertThat(result.identifications.map { it.guid }).isEqualTo(listOf(fingerprintBigConfidence, fingerprintSmallConfidence)) - assertThat(result.identifications.map { it.confidenceScore }).isEqualTo(listOf(bigConfidence.toInt(), smallConfidence.toInt())) + assertThat( + result.identifications.map { + it.guid + }, + ).isEqualTo(listOf(fingerprintBigConfidenceGUID, faceBigConfidenceGUID, fingerprintSmallConfidenceGUID)) + assertThat( + result.identifications.map { + it.confidenceScore + }, + ).isEqualTo(listOf(fingerprintBigConfidence.toInt(), faceBigConfidence.toInt(), smallConfidence.toInt())) } @Test @@ -423,6 +370,86 @@ class CreateIdentifyResponseUseCaseTest { assertThat(result.identifications.first().confidenceScore).isEqualTo(credentialConfidence.toInt()) } + @Test + fun `Returns credential results prioritized over match results when max candidates is limited`() = runTest { + val credentialConfidence1 = 85f + val credentialConfidence2 = 90f + val credentialConfidence3 = 80f + val credentialGuid1 = "credentialGuid1; confidence=$credentialConfidence1" + val credentialGuid2 = "credentialGuid2; confidence=$credentialConfidence2" + val credentialGuid3 = "credentialGuid3; confidence=$credentialConfidence3" + + val matchConfidence1 = 95f + val matchConfidence2 = 92f + val matchConfidence3 = 88f + val matchConfidence4 = 83f + val matchConfidence5 = 78f + val targetMatchGuid1 = "0" // based on id assigned in 'createFaceMatchResult' + val targetMatchGuid2 = "1" // based on id assigned in 'createFaceMatchResult' + + val maxNbOfReturnedCandidates = 5 + + val credentialFaceMatches = listOf( + mockk { + every { matchResult } returns FaceMatchResult.Item( + subjectId = credentialGuid1, + confidence = credentialConfidence1, + ) + every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE + every { fingerprintBioSdk } returns null + }, + mockk { + every { matchResult } returns FaceMatchResult.Item( + subjectId = credentialGuid2, + confidence = credentialConfidence2, + ) + every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE + every { fingerprintBioSdk } returns null + }, + mockk { + every { matchResult } returns FaceMatchResult.Item( + subjectId = credentialGuid3, + confidence = credentialConfidence3, + ) + every { faceBioSdk } returns FaceConfiguration.BioSdk.RANK_ONE + every { fingerprintBioSdk } returns null + }, + ) + + val faceMatchResults = + createFaceMatchResult(matchConfidence1, matchConfidence2, matchConfidence3, matchConfidence4, matchConfidence5) + + val result = useCase( + mockk { + every { identification.maxNbOfReturnedCandidates } returns maxNbOfReturnedCandidates + every { multifactorId?.allowedExternalCredentials } returns null + every { face?.getSdkConfiguration(FaceConfiguration.BioSdk.RANK_ONE)?.decisionPolicy } returns DecisionPolicy(20, 50, 100) + every { face?.getSdkConfiguration(FaceConfiguration.BioSdk.RANK_ONE)?.verificationMatchThreshold } returns 0.0f + every { fingerprint?.getSdkConfiguration((any()))?.decisionPolicy } returns null + }, + results = listOf( + mockk { + every { matchResults } returns credentialFaceMatches + }, + faceMatchResults, + ), + ) + + assertThat((result as AppIdentifyResponse).identifications).hasSize(maxNbOfReturnedCandidates) + assertThat(result.identifications.map { it.guid }).isEqualTo( + listOf(credentialGuid2, credentialGuid1, credentialGuid3, targetMatchGuid1, targetMatchGuid2), + ) + assertThat(result.identifications.map { it.confidenceScore }).isEqualTo( + listOf( + credentialConfidence2.toInt(), + credentialConfidence1.toInt(), + credentialConfidence3.toInt(), + matchConfidence1.toInt(), + matchConfidence2.toInt(), + ), + ) + } + private fun createFaceMatchResult(vararg confidences: Float): Serializable = FaceMatchResult( confidences.mapIndexed { i, confidence -> FaceMatchResult.Item(subjectId = "$i", confidence = confidence) }, FaceConfiguration.BioSdk.RANK_ONE,