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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) }
}
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Subject>,
Expand All @@ -24,10 +28,11 @@ internal class MatchCandidatesUseCase @Inject constructor(
project: Project,
projectConfig: ProjectConfiguration,
): List<CredentialMatch> = 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,
Expand All @@ -39,35 +44,43 @@ 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
}
}
}

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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(),
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using orEmpty() on probeReferenceId silently converts null to an empty string. Consider whether an empty string is semantically correct for this field, or if null should be preserved or handled differently. If an empty string is intentional when the value is absent, this is acceptable.

Suggested change
probeBiometricReferenceId = match.probeReferenceId.orEmpty(),
probeBiometricReferenceId = match.probeReferenceId,

Copilot uses AI. Check for mistakes.
),
)
}

suspend fun saveSearchEvent(
startTime: Timestamp,
externalCredentialId: String,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -88,6 +95,8 @@ internal class MatchCandidatesUseCaseTest {
createMatchParamsUseCase = createMatchParamsUseCase,
faceMatcher = faceMatcher,
fingerprintMatcher = fingerprintMatcher,
timeHelper = timeHelper,
eventsTracker = eventsTracker,
)

every { subject.subjectId } returns subjectId
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<FingerprintConfiguration.FingerprintSdkConfiguration> {
every { comparisonStrategyForVerification } returns
FingerprintConfiguration.FingerComparisonStrategy.SAME_FINGER
}

val projectConfig = mockk<ProjectConfiguration> {
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(),
Expand All @@ -179,6 +228,43 @@ class ExternalCredentialEventTrackerUseCaseTest {
scannedValue = RAW_SCANNED_VALUE.asTokenizableRaw(),
)

private fun makeCredentialMatch(
faceSdk: FaceConfiguration.BioSdk?,
fingerprintSdk: FingerprintConfiguration.BioSdk?,
): CredentialMatch {
val matchResult = mockk<com.simprints.infra.matching.MatchResultItem> {
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<OneToOneMatchEvent>()
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)
Expand All @@ -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
}
}
Loading