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/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 5cdc2a3709..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 @@ -8,25 +8,21 @@ 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.screens.search.model.toExternalCredential +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent -import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent -import com.simprints.infra.events.session.SessionEventRepository -import com.simprints.infra.logging.Simber +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch -import java.util.UUID 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 eventRepository: SessionEventRepository, + private val eventsTracker: ExternalCredentialEventTrackerUseCase, ) : ViewModel() { private var isInitialized = false lateinit var params: ExternalCredentialParams @@ -46,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() @@ -54,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) { @@ -76,28 +97,16 @@ internal class ExternalCredentialViewModel @Inject internal constructor( fun finish(result: ExternalCredentialSearchResult) { viewModelScope.launch { - result.scannedCredential?.let { scannedCredential -> - Simber.d("Saving External Credential Events for $scannedCredential") - val credential = scannedCredential.toExternalCredential(params.subjectId.orEmpty()) - eventRepository.addOrUpdateEvent( - ExternalCredentialCaptureValueEvent( - createdAt = timeHelper.now(), - id = UUID.randomUUID().toString(), - credential = credential, - ), - ) - eventRepository.addOrUpdateEvent( - ExternalCredentialCaptureEvent( - createdAt = timeHelper.now(), - id = UUID.randomUUID().toString(), - endTime = timeHelper.now(), - autoCaptureStartTime = timeHelper.now(), - autoCaptureEndTime = timeHelper.now(), - ocrErrorCount = 0, - capturedTextLength = 0, - credentialTextLength = 0, - selectionId = credential.id, - ), + 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 e6ff933d30..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 @@ -87,6 +88,7 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext fillRecyclerView(externalCredentialTypes) initViews(externalCredentialTypes) initListeners(externalCredentialTypes) + mainViewModel.selectionStarted() } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt index edec286386..e0e7675cc3 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/skip/ExternalCredentialSkipFragment.kt @@ -13,6 +13,7 @@ import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSkipBinding import com.simprints.feature.externalcredential.ext.getCredentialTypeString import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @@ -39,19 +40,15 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede } private fun initViews(credentialTypes: List) = with(binding) { - val dynamicTextReasonItemMap = - mapOf( - title to IDR.string.mfid_skip_title, - skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_does_not_have, - skipReasonDidNotBring to IDR.string.mfid_skip_reason_did_not_bring, - skipReasonIncorrect to IDR.string.mfid_skip_reason_incorrect, - skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_does_not_want_to_provide, - skipReasonDamaged to IDR.string.mfid_skip_reason_damaged, - skipReasonUnableToScan to IDR.string.mfid_skip_reason_unable_to_scan, - ) - dynamicTextReasonItemMap.forEach { entry -> - val textView = entry.key - val stringRes = entry.value + mapOf( + title to IDR.string.mfid_skip_title, + skipReasonDoesNotHaveDocument to IDR.string.mfid_skip_reason_does_not_have, + skipReasonDidNotBring to IDR.string.mfid_skip_reason_did_not_bring, + skipReasonIncorrect to IDR.string.mfid_skip_reason_incorrect, + skipReasonDoesNotWantToProvide to IDR.string.mfid_skip_reason_does_not_want_to_provide, + skipReasonDamaged to IDR.string.mfid_skip_reason_damaged, + skipReasonUnableToScan to IDR.string.mfid_skip_reason_unable_to_scan, + ).forEach { (textView, stringRes) -> val credentialText = when (credentialTypes.size) { 1 -> resources.getCredentialTypeString(credentialTypes.first()) else -> getString(IDR.string.mfid_type_any_document) @@ -62,6 +59,7 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede private fun initListeners() = with(binding) { skipCredentialScanRadioGroup.setOnCheckedChangeListener { _, checkedId -> + mainViewModel.skipOptionSelected(viewIdToOption(checkedId)) reasonTextInputLayout.isVisible = checkedId == R.id.skipReasonOther val isSkipButtonEnabled = when (checkedId) { @@ -75,7 +73,9 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede } reasonTextInput.addTextChangedListener( afterTextChanged = { - buttonSkip.isEnabled = it.toString().isNotEmpty() + val text = it.toString() + mainViewModel.skipOtherReasonChanged(text) + buttonSkip.isEnabled = text.isNotEmpty() }, ) buttonGoBack.setOnClickListener { @@ -93,4 +93,14 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede ) } } + + private fun viewIdToOption(checkedId: Int) = when (checkedId) { + R.id.skipReasonDoesNotHaveDocument -> ExternalCredentialSelectionEvent.SkipReason.DOES_NOT_HAVE_ID + R.id.skipReasonDidNotBring -> ExternalCredentialSelectionEvent.SkipReason.DID_NOT_BRING_ID + R.id.skipReasonIncorrect -> ExternalCredentialSelectionEvent.SkipReason.BROUGHT_INCORRECT_ID + R.id.skipReasonDoesNotWantToProvide -> ExternalCredentialSelectionEvent.SkipReason.NO_CONSENT + R.id.skipReasonDamaged -> ExternalCredentialSelectionEvent.SkipReason.ID_DAMAGED + R.id.skipReasonUnableToScan -> ExternalCredentialSelectionEvent.SkipReason.UNABLE_TO_SCAN + else -> ExternalCredentialSelectionEvent.SkipReason.OTHER + } } 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 new file mode 100644 index 0000000000..65a7e57218 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCase.kt @@ -0,0 +1,138 @@ +package com.simprints.feature.externalcredential.usecase + +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.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.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.domain.models.Subject +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent +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.session.SessionEventRepository +import com.simprints.infra.logging.Simber +import javax.inject.Inject + +internal class ExternalCredentialEventTrackerUseCase @Inject constructor( + private val timeHelper: TimeHelper, + private val authStore: AuthStore, + private val configManager: ConfigManager, + private val tokenizationProcessor: TokenizationProcessor, + private val eventRepository: SessionEventRepository, + private val calculateDistance: CalculateLevenshteinDistanceUseCase, +) { + suspend fun saveSearchEvent( + startTime: Timestamp, + externalCredentialId: String, + candidates: List, + ) { + eventRepository.addOrUpdateEvent( + ExternalCredentialSearchEvent( + createdAt = startTime, + endedAt = timeHelper.now(), + probeExternalCredentialId = externalCredentialId, + candidateIds = candidates.map { it.subjectId }, + ), + ) + } + + suspend fun saveCaptureEvents( + startTime: Timestamp, + subjectId: String, + scannedCredential: ScannedCredential, + selectionEventId: String, + ) { + Simber.d("Saving External Credential Events for $scannedCredential") + val credential = scannedCredential.toExternalCredential(subjectId) + eventRepository.addOrUpdateEvent( + ExternalCredentialCaptureValueEvent( + createdAt = timeHelper.now(), + payloadId = scannedCredential.credentialScanId, + credential = credential, + ), + ) + + eventRepository.addOrUpdateEvent( + ExternalCredentialCaptureEvent( + startTime = startTime, + endTime = timeHelper.now(), + payloadId = scannedCredential.credentialScanId, + autoCaptureStartTime = scannedCredential.scanStartTime, + autoCaptureEndTime = scannedCredential.scanEndTime, + ocrErrorCount = calculateOcrErrorCount(scannedCredential), + capturedTextLength = getActualCapturedCredentialLength(scannedCredential), + credentialTextLength = getExpectedCredentialValueLength(credential), + selectionId = selectionEventId, + ), + ) + } + + private fun getActualCapturedCredentialLength(scannedCredential: ScannedCredential): Int = scannedCredential.scannedValue.value.length + + private fun getExpectedCredentialValueLength(credential: ExternalCredential): Int = when (credential.type) { + ExternalCredentialType.NHISCard -> NHIS_CARD_ID_LENGTH + ExternalCredentialType.GhanaIdCard -> GHANA_ID_CARD_ID_LENGTH + ExternalCredentialType.QRCode -> QR_CODE_LENGTH + } + + private suspend fun calculateOcrErrorCount(scannedCredential: ScannedCredential): Int { + val project = configManager.getProject(authStore.signedInProjectId) + val actualCredentialRaw = tokenizationProcessor.decrypt( + scannedCredential.credential, + TokenKeyType.ExternalCredential, + project, + ) + return calculateDistance( + scannedCredential.scannedValue.value, + actualCredentialRaw.value, + ) + } + + suspend fun saveSelectionEvent( + startTime: Timestamp, + endTime: Timestamp, + selectedType: ExternalCredentialType, + ): String { + val event = ExternalCredentialSelectionEvent(startTime, endTime, selectedType) + eventRepository.addOrUpdateEvent(event) + return event.id + } + + suspend fun saveSkippedEvent( + startTime: Timestamp, + skipReason: ExternalCredentialSelectionEvent.SkipReason, + skipOther: String?, + ) { + eventRepository.addOrUpdateEvent( + ExternalCredentialSelectionEvent(startTime, timeHelper.now(), skipReason, skipOther), + ) + } + + suspend fun saveConfirmation( + startTime: Timestamp, + result: ExternalCredentialConfirmationResult, + ) { + eventRepository.addOrUpdateEvent( + ExternalCredentialConfirmationEvent( + createdAt = startTime, + endedAt = timeHelper.now(), + result = result, + ), + ) + } + + companion object Companion { + private const val NHIS_CARD_ID_LENGTH = 8 + private const val GHANA_ID_CARD_ID_LENGTH = 15 + private const val QR_CODE_LENGTH = 6 + } +} diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt index 16742200b5..669d457236 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModelTest.kt @@ -1,23 +1,28 @@ package com.simprints.feature.externalcredential.screens.controller import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.common.truth.Truth.* +import com.google.common.truth.Truth.assertThat import com.jraska.livedata.test import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent -import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent -import com.simprints.infra.events.session.SessionEventRepository +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.* +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -31,7 +36,7 @@ internal class ExternalCredentialViewModelTest { val testCoroutineRule = TestCoroutineRule() @MockK - lateinit var eventRepository: SessionEventRepository + lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase @MockK lateinit var timeHelper: TimeHelper @@ -43,7 +48,12 @@ internal class ExternalCredentialViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - viewModel = ExternalCredentialViewModel(configManager = configManager, timeHelper = timeHelper, eventRepository = eventRepository) + viewModel = ExternalCredentialViewModel( + configManager = configManager, + timeHelper = timeHelper, + eventsTracker = eventsTracker, + ) + every { timeHelper.now() } returns Timestamp(1L) } @Test @@ -56,6 +66,7 @@ internal class ExternalCredentialViewModelTest { fun `setSelectedExternalCredentialType updates state`() { val observer = viewModel.stateLiveData.test() + viewModel.selectionStarted() viewModel.setSelectedExternalCredentialType(ExternalCredentialType.GhanaIdCard) assertThat(observer.value()?.selectedType).isEqualTo(ExternalCredentialType.GhanaIdCard) @@ -120,6 +131,8 @@ internal class ExternalCredentialViewModelTest { every { scannedCredential } returns createScannedCredential() } viewModel.init(params) + viewModel.selectionStarted() + viewModel.setSelectedExternalCredentialType(ExternalCredentialType.QRCode) // init capture timer viewModel.finish(credentialSearchResult) val observer = viewModel.finishEvent @@ -128,8 +141,35 @@ internal class ExternalCredentialViewModelTest { .getContentIfNotHandled() assertThat(observer).isEqualTo(credentialSearchResult) - coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(ofType()) } - coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(ofType()) } + } + + @Test + fun `finish saves success flow events`() = runTest { + val mockResult = mockk(relaxed = true) { + every { scannedCredential } returns mockk(relaxed = true) + } + coEvery { eventsTracker.saveSelectionEvent(any(), any(), any()) } returns "selectionId" + + viewModel.selectionStarted() + viewModel.init(createParams(subjectId = "subjectId", FlowType.IDENTIFY)) + viewModel.setSelectedExternalCredentialType(ExternalCredentialType.QRCode) // init capture timer + viewModel.finish(mockResult) + + coVerify { eventsTracker.saveSelectionEvent(any(), any(), any()) } + coVerify { eventsTracker.saveCaptureEvents(any(), any(), any(), any()) } + } + + @Test + fun `finish saves skip event`() = runTest { + val mockResult = mockk(relaxed = true) { + every { scannedCredential } returns null + } + viewModel.selectionStarted() + viewModel.skipOptionSelected(ExternalCredentialSelectionEvent.SkipReason.OTHER) + viewModel.skipOtherReasonChanged("other") + viewModel.finish(mockResult) + + coVerify { eventsTracker.saveSkippedEvent(any(), any(), any()) } } @Test @@ -156,7 +196,11 @@ internal class ExternalCredentialViewModelTest { every { allowedExternalCredentials } returns allowedCredentials } } - return ExternalCredentialViewModel(configManager = configManager, timeHelper = timeHelper, eventRepository = eventRepository) + return ExternalCredentialViewModel( + configManager = configManager, + timeHelper = timeHelper, + eventsTracker = eventsTracker, + ) } private fun createScannedCredential( @@ -171,6 +215,9 @@ internal class ExternalCredentialViewModelTest { documentImagePath = documentImagePath, zoomedCredentialImagePath = zoomedCredentialImagePath, credentialBoundingBox = credentialBoundingBox, + scanStartTime = Timestamp(1L), + scanEndTime = Timestamp(2L), + scannedValue = credential.asTokenizableRaw(), ) private fun createParams( diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt index 85c0d90b86..bf70d7fc5c 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModelTest.kt @@ -5,6 +5,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.* import com.jraska.livedata.test import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.model.BoundingBox import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig @@ -28,6 +30,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.concurrent.timer internal class ExternalCredentialScanOcrViewModelTest { @get:Rule @@ -36,6 +39,9 @@ internal class ExternalCredentialScanOcrViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() + @MockK + private lateinit var timeHelper: TimeHelper + @MockK private lateinit var normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase @@ -77,10 +83,13 @@ internal class ExternalCredentialScanOcrViewModelTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) viewModel = initViewModel(documentType) + + every { timeHelper.now() } returns Timestamp(1L) } private fun initViewModel(documentType: OcrDocumentType) = ExternalCredentialScanOcrViewModel( ocrDocumentType = documentType, + timeHelper = timeHelper, normalizeBitmapToPreviewUseCase = normalizeBitmapToPreviewUseCase, cropDocumentFromPreviewUseCase = cropDocumentFromPreviewUseCase, getCredentialCoordinatesUseCase = getCredentialCoordinatesUseCase, @@ -151,6 +160,7 @@ internal class ExternalCredentialScanOcrViewModelTest { val finishObserver = viewModel.finishOcrEvent.test() val stateObserver = viewModel.stateLiveData.test() + viewModel.ocrStarted() // Initialises capture timing viewModel.processOcrResultsAndFinish() val scannedCredential = finishObserver.value()?.peekContent() @@ -186,6 +196,7 @@ internal class ExternalCredentialScanOcrViewModelTest { val finishObserver = viewModel.finishOcrEvent.test() + viewModel.ocrStarted() // Initialises capture timing viewModel.processOcrResultsAndFinish() val scannedCredential = finishObserver.value()?.peekContent() diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt index 18725a6c03..eaa430a722 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrViewModelTest.kt @@ -6,6 +6,8 @@ import com.jraska.livedata.test import com.simprints.core.domain.permission.PermissionStatus import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp import com.simprints.feature.externalcredential.screens.scanqr.usecase.ExternalCredentialQrCodeValidatorUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.Project @@ -27,6 +29,9 @@ internal class ExternalCredentialScanQrViewModelTest { @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + @MockK + private lateinit var timeHelper: TimeHelper + @MockK private lateinit var validator: ExternalCredentialQrCodeValidatorUseCase @@ -45,11 +50,14 @@ internal class ExternalCredentialScanQrViewModelTest { fun setUp() { MockKAnnotations.init(this, relaxed = true) viewModel = ExternalCredentialScanQrViewModel( + timeHelper = timeHelper, externalCredentialQrCodeValidator = validator, tokenizationProcessor = tokenizationProcessor, configManager = configManager, authStore = authStore, ) + + every { timeHelper.now() } returns Timestamp(1L) } @Test @@ -77,9 +85,15 @@ internal class ExternalCredentialScanQrViewModelTest { coEvery { configManager.getProject(projectId) } returns mockProject coEvery { tokenizationProcessor.encrypt(any(), TokenKeyType.ExternalCredential, mockProject) } returns mockTokenizedCredential + viewModel.updateCameraPermissionStatus(permissionStatus = PermissionStatus.Granted) // inits the capture timing viewModel.updateCapturedValue(value) - val expected = ScanQrState.QrCodeCaptured(value.asTokenizableRaw(), mockTokenizedCredential) + val expected = ScanQrState.QrCodeCaptured( + scanStartTime = Timestamp(1L), + scanEndTime = Timestamp(1L), + qrCode = value.asTokenizableRaw(), + qrCodeEncrypted = mockTokenizedCredential, + ) assertThat(observer.value()).isEqualTo(expected) } diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt index 7f6c01ab90..e72cf4d9a9 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt @@ -8,12 +8,15 @@ import com.simprints.core.domain.common.FlowType 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.externalcredential.model.CredentialMatch import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.feature.externalcredential.screens.search.model.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.ProjectConfiguration import com.simprints.infra.config.store.models.TokenKeyType @@ -37,6 +40,9 @@ internal class ExternalCredentialSearchViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() + @MockK + lateinit var timeHelper: TimeHelper + @MockK lateinit var authStore: AuthStore @@ -59,7 +65,7 @@ internal class ExternalCredentialSearchViewModelTest { lateinit var candidateMatch: CredentialMatch @MockK - lateinit var scannedCredential: ScannedCredential + lateinit var mockScannedCredential: ScannedCredential @MockK lateinit var externalCredentialParams: ExternalCredentialParams @@ -70,6 +76,9 @@ internal class ExternalCredentialSearchViewModelTest { @MockK private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository + @MockK + lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase + private lateinit var viewModel: ExternalCredentialSearchViewModel private val projectId = "projectId" @@ -77,20 +86,25 @@ internal class ExternalCredentialSearchViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { timeHelper.now() } returns Timestamp(1L) every { authStore.signedInProjectId } returns projectId coEvery { configManager.getProject(projectId) } returns project coEvery { configManager.getProjectConfiguration() } returns projectConfig + coJustRun { eventsTracker.saveSearchEvent(any(), any(), any()) } + coJustRun { eventsTracker.saveConfirmation(any(), any()) } viewModel = createViewModel() } fun createViewModel() = ExternalCredentialSearchViewModel( - scannedCredential = scannedCredential, + scannedCredential = mockScannedCredential, externalCredentialParams = externalCredentialParams, + timeHelper = timeHelper, authStore = authStore, configManager = configManager, matchCandidatesUseCase = matchCandidatesUseCase, tokenizationProcessor = tokenizationProcessor, enrolmentRecordRepository = enrolmentRecordRepository, + eventsTracker = eventsTracker, ) @Test @@ -103,9 +117,10 @@ internal class ExternalCredentialSearchViewModelTest { val observer = viewModel.stateLiveData.test() assertThat(observer.value()?.searchState).isEqualTo(SearchState.CredentialNotFound) - assertThat(observer.value()?.scannedCredential).isEqualTo(scannedCredential) + assertThat(observer.value()?.scannedCredential).isEqualTo(mockScannedCredential) assertThat(observer.value()?.isConfirmed).isFalse() assertThat(observer.value()?.displayedCredential).isEqualTo(decryptedCredential) + coVerify { eventsTracker.saveSearchEvent(any(), any(), any()) } } @Test @@ -122,6 +137,7 @@ internal class ExternalCredentialSearchViewModelTest { val searchState = viewModel.stateLiveData.value?.searchState as SearchState.CredentialLinked assertThat(searchState.matchResults).hasSize(1) assertThat(searchState.matchResults.first()).isEqualTo(candidateMatch) + coVerify { eventsTracker.saveSearchEvent(any(), any(), any()) } } @Test @@ -188,7 +204,7 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `getKeyBoardInputType returns number for NHIS card`() = runTest { - every { scannedCredential.credentialType } returns ExternalCredentialType.NHISCard + every { mockScannedCredential.credentialType } returns ExternalCredentialType.NHISCard viewModel = createViewModel() val result = viewModel.getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_NUMBER) @@ -196,7 +212,7 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `getKeyBoardInputType returns text for Ghana ID card`() = runTest { - every { scannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard + every { mockScannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard viewModel = createViewModel() val result = viewModel.getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_TEXT) @@ -204,7 +220,7 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `getKeyBoardInputType returns text for QR code`() = runTest { - every { scannedCredential.credentialType } returns ExternalCredentialType.QRCode + every { mockScannedCredential.credentialType } returns ExternalCredentialType.QRCode viewModel = createViewModel() val result = viewModel.getKeyBoardInputType() assertThat(result).isEqualTo(InputType.TYPE_CLASS_TEXT) @@ -214,21 +230,21 @@ internal class ExternalCredentialSearchViewModelTest { fun `finish sends empty matches when credential not found`() = runTest { viewModel = createViewModel() val state = mockk { - every { scannedCredential } returns this@ExternalCredentialSearchViewModelTest.scannedCredential + every { scannedCredential } returns mockScannedCredential every { searchState } returns SearchState.CredentialNotFound } viewModel.finish(state) val finishEvent = viewModel.finishEvent.value?.peekContent() assertThat(finishEvent).isNotNull() assertThat(finishEvent?.matchResults).isEmpty() - assertThat(finishEvent?.scannedCredential).isEqualTo(scannedCredential) + assertThat(finishEvent?.scannedCredential).isEqualTo(mockScannedCredential) } @Test fun `finish sends empty matches when still searching`() = runTest { viewModel = createViewModel() val state = mockk { - every { scannedCredential } returns this@ExternalCredentialSearchViewModelTest.scannedCredential + every { scannedCredential } returns mockScannedCredential every { searchState } returns SearchState.Searching } viewModel.finish(state) @@ -240,7 +256,7 @@ internal class ExternalCredentialSearchViewModelTest { @Test fun `finish sends match results when credential linked`() = runTest { val state = mockk { - every { scannedCredential } returns this@ExternalCredentialSearchViewModelTest.scannedCredential + every { scannedCredential } returns mockScannedCredential every { searchState } returns mockk { every { matchResults } returns listOf(candidateMatch) } @@ -250,7 +266,27 @@ internal class ExternalCredentialSearchViewModelTest { assertThat(finishEvent).isNotNull() assertThat(finishEvent?.matchResults).hasSize(1) assertThat(finishEvent?.matchResults?.first()).isEqualTo(candidateMatch) - assertThat(finishEvent?.scannedCredential).isEqualTo(scannedCredential) + assertThat(finishEvent?.scannedCredential).isEqualTo(mockScannedCredential) + } + + @Test + fun `trackRecapture sends confirmation event`() = runTest { + viewModel.trackRecapture() + + coVerify { eventsTracker.saveConfirmation(any(), any()) } + } + + @Test + fun `finish sends confirmation event`() = runTest { + val state = mockk { + every { scannedCredential } returns mockScannedCredential + every { searchState } returns mockk { + every { matchResults } returns listOf(candidateMatch) + } + } + viewModel.finish(state) + + coVerify { eventsTracker.saveConfirmation(any(), any()) } } @Test @@ -258,7 +294,7 @@ internal class ExternalCredentialSearchViewModelTest { val encryptedCredential = mockk() val decryptedCredential = "decryptedValue".asTokenizableRaw() - every { scannedCredential.credential } returns encryptedCredential + every { mockScannedCredential.credential } returns encryptedCredential coEvery { tokenizationProcessor.decrypt(encryptedCredential, TokenKeyType.ExternalCredential, project) } returns decryptedCredential coEvery { enrolmentRecordRepository.load(any()) } returns emptyList() diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt index b18c3c3724..bffc349cd3 100644 --- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredentialTest.kt @@ -2,29 +2,31 @@ package com.simprints.feature.externalcredential.screens.search.model import com.google.common.truth.Truth.* import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import io.mockk.* -import org.junit.Before +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.core.tools.time.Timestamp import org.junit.Test -import java.util.UUID class ScannedCredentialTest { private val testUuid = "testUuid" private val subjectId = "subjectId" - @Before - fun setup() { - mockkStatic(UUID::class) - every { UUID.randomUUID().toString() } returns testUuid - } - @Test fun `toExternalCredential maps fields correctly`() { val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() - val scannedCredential = mockk { - every { credential } returns tokenizedValue - every { credentialType } returns ExternalCredentialType.NHISCard - } + val scannedCredential = ScannedCredential( + credentialScanId = testUuid, + credential = tokenizedValue, + credentialType = ExternalCredentialType.NHISCard, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = Timestamp(1L), + scanEndTime = Timestamp(2L), + scannedValue = "".asTokenizableRaw(), + ) + val result = scannedCredential.toExternalCredential(subjectId) assertThat(result.id).isEqualTo(testUuid) 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 new file mode 100644 index 0000000000..e006fdcfcb --- /dev/null +++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/usecase/ExternalCredentialEventTrackerUseCaseTest.kt @@ -0,0 +1,193 @@ +package com.simprints.feature.externalcredential.usecase + +import com.google.common.truth.Truth.* +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.externalcredential.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.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent +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.session.SessionEventRepository +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ExternalCredentialEventTrackerUseCaseTest { + @MockK + private lateinit var timeHelper: TimeHelper + + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configManager: ConfigManager + + @MockK + private lateinit var tokenizationProcessor: TokenizationProcessor + + @MockK + private lateinit var eventRepository: SessionEventRepository + + @MockK + private lateinit var calculateDistance: CalculateLevenshteinDistanceUseCase + + private lateinit var useCase: ExternalCredentialEventTrackerUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + useCase = ExternalCredentialEventTrackerUseCase( + timeHelper = timeHelper, + authStore = authStore, + configManager = configManager, + tokenizationProcessor = tokenizationProcessor, + eventRepository = eventRepository, + calculateDistance = calculateDistance, + ) + + every { timeHelper.now() } returns END_TIME + + coEvery { authStore.signedInProjectId } returns "" + coEvery { configManager.getProject(any()) } returns mockk() + coEvery { + tokenizationProcessor.decrypt(any(), TokenKeyType.ExternalCredential, any()) + } returns RAW_SCANNED_VALUE.asTokenizableRaw() + + coEvery { calculateDistance(any(), any()) } returns DEFAULT_DISTANCE + } + + @Test + fun `saveCaptureEvents should save external credential capture events`() = runTest { + val scannedCredential = makeScannedCredential(ExternalCredentialType.QRCode) + useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + + val valueEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(valueEventSlot)) } + with(valueEventSlot.captured) { + assertThat(payload.credential.id).isEqualTo(SCAN_ID) + assertThat(payload.credential.subjectId).isEqualTo(SUBJECT_ID) + } + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + with(captureEventSlot.captured) { + assertThat(payload.id).isEqualTo(SCAN_ID) + assertThat(payload.createdAt).isEqualTo(START_TIME) + assertThat(payload.endedAt).isEqualTo(END_TIME) + assertThat(payload.autoCaptureStartTime).isEqualTo(SCAN_START_TIME) + assertThat(payload.autoCaptureEndTime).isEqualTo(SCAN_END_TIME) + assertThat(payload.ocrErrorCount).isEqualTo(DEFAULT_DISTANCE) + assertThat(payload.capturedTextLength).isEqualTo(RAW_SCANNED_VALUE.length) + } + } + + @Test + fun `saveCaptureEvents should correctly calculate length for NHISCard`() = runTest { + val scannedCredential = makeScannedCredential(ExternalCredentialType.NHISCard) + useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + assertThat(captureEventSlot.captured.payload.credentialTextLength).isEqualTo(8) + } + + @Test + fun `saveCaptureEvents should correctly calculate length for GhanaIdCard`() = runTest { + val scannedCredential = makeScannedCredential(ExternalCredentialType.GhanaIdCard) + useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + assertThat(captureEventSlot.captured.payload.credentialTextLength).isEqualTo(15) + } + + @Test + fun `saveCaptureEvents should correctly calculate length for QRCode`() = runTest { + val scannedCredential = makeScannedCredential(ExternalCredentialType.QRCode) + useCase.saveCaptureEvents(START_TIME, SUBJECT_ID, scannedCredential, SELECTION_ID) + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + assertThat(captureEventSlot.captured.payload.credentialTextLength).isEqualTo(6) + } + + @Test + fun `saveSelectionEvent should save correct event`() = runTest { + useCase.saveSelectionEvent(START_TIME, END_TIME, ExternalCredentialType.QRCode) + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + with(captureEventSlot.captured) { + assertThat(payload.createdAt).isEqualTo(START_TIME) + assertThat(payload.endedAt).isEqualTo(END_TIME) + assertThat(payload.credentialType).isEqualTo(ExternalCredentialType.QRCode) + assertThat(payload.skipReason).isNull() + assertThat(payload.skipOther).isNull() + } + } + + @Test + fun `saveSkippedEvent should save correct event`() = runTest { + useCase.saveSkippedEvent(START_TIME, SkipReason.OTHER, "other") + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + with(captureEventSlot.captured) { + assertThat(payload.createdAt).isEqualTo(START_TIME) + assertThat(payload.endedAt).isEqualTo(END_TIME) + assertThat(payload.credentialType).isNull() + assertThat(payload.skipReason).isEqualTo(SkipReason.OTHER) + assertThat(payload.skipOther).isEqualTo("other") + } + } + + @Test + fun `saveConfirmation should save correct event`() = runTest { + useCase.saveConfirmation(START_TIME, ExternalCredentialConfirmationResult.CONTINUE) + + val captureEventSlot = slot() + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent(capture(captureEventSlot)) } + with(captureEventSlot.captured) { + assertThat(payload.createdAt).isEqualTo(START_TIME) + assertThat(payload.endedAt).isEqualTo(END_TIME) + assertThat(payload.result).isEqualTo(ExternalCredentialConfirmationResult.CONTINUE) + } + } + + private fun makeScannedCredential(type: ExternalCredentialType) = ScannedCredential( + credentialScanId = "test-scan-id", + credential = RAW_SCANNED_VALUE.asTokenizableEncrypted(), + credentialType = type, + documentImagePath = null, + zoomedCredentialImagePath = null, + credentialBoundingBox = null, + scanStartTime = SCAN_START_TIME, + scanEndTime = SCAN_END_TIME, + scannedValue = RAW_SCANNED_VALUE.asTokenizableRaw(), + ) + + companion object Companion { + private val START_TIME = Timestamp(0L) + private val SCAN_START_TIME = Timestamp(3L) + private val SCAN_END_TIME = Timestamp(4L) + private val END_TIME = Timestamp(6L) + private const val SCAN_ID = "test-scan-id" + private const val SUBJECT_ID = "test-subject-id" + private const val RAW_SCANNED_VALUE = "scanned-value" + private const val DEFAULT_DISTANCE = 7 + private const val SELECTION_ID = "selection_id" + } +} diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt index 8b3480774e..df5c2419a2 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateEnrolResponseUseCaseTest.kt @@ -106,6 +106,7 @@ internal class CreateEnrolResponseUseCaseTest { fun `correctly processes external credential result`() = runTest { val externalCredentialType = ExternalCredentialType.GhanaIdCard val scannedCredentialMock = mockk { + every { credentialScanId } returns "scanId" every { credential } returns credentialEncrypted every { credentialType } returns externalCredentialType } diff --git a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt index f3dcf07fa1..d18a4d25cf 100644 --- a/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt +++ b/feature/select-subject/src/test/java/com/simprints/feature/selectsubject/screen/SelectSubjectViewModelTest.kt @@ -162,6 +162,7 @@ internal class SelectSubjectViewModelTest { val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() val type = ExternalCredentialType.NHISCard val scannedCredential = mockk { + every { credentialScanId } returns "credentialId" every { credential } returns tokenizedValue every { credentialType } returns type } @@ -222,6 +223,7 @@ internal class SelectSubjectViewModelTest { val tokenizedValue = "tokenizedValue".asTokenizableEncrypted() val type = ExternalCredentialType.NHISCard val scannedCredential = mockk { + every { credentialScanId } returns "credentialId" every { credential } returns tokenizedValue every { credentialType } returns type } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt index 31995e63e0..89212b9d5e 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/remote/models/ApiMultiFactorIdConfiguration.kt @@ -3,6 +3,9 @@ package com.simprints.infra.config.store.remote.models import androidx.annotation.Keep import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.infra.config.store.models.MultiFactorIdConfiguration +import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.GHANA_CARD +import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.NHIS_CARD +import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType.QR_CODE @Keep internal data class ApiMultiFactorIdConfiguration( @@ -26,3 +29,9 @@ enum class ApiExternalCredentialType { QR_CODE -> ExternalCredentialType.QRCode } } + +fun ExternalCredentialType.fromDomainToApi(): ApiExternalCredentialType = when (this) { + ExternalCredentialType.NHISCard -> NHIS_CARD + ExternalCredentialType.GhanaIdCard -> GHANA_CARD + ExternalCredentialType.QRCode -> QR_CODE +} diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/Timestamp.kt b/infra/core/src/main/java/com/simprints/core/tools/time/Timestamp.kt index 687a0bfe68..8b10b97f48 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/Timestamp.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/Timestamp.kt @@ -1,12 +1,14 @@ package com.simprints.core.tools.time import androidx.annotation.Keep +import java.io.Serializable @Keep data class Timestamp( val ms: Long, val isTrustworthy: Boolean = false, val msSinceBoot: Long? = null, -) : Comparable { +) : Comparable, + Serializable { override fun compareTo(other: Timestamp): Int = ms.compareTo(other.ms) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt index f553cbfbab..54ace30792 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt @@ -47,6 +47,9 @@ import com.simprints.infra.events.event.domain.models.EventType.EVENT_DOWN_SYNC_ import com.simprints.infra.events.event.domain.models.EventType.EVENT_UP_SYNC_REQUEST import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CAPTURE import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CAPTURE_VALUE +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CONFIRMATION +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SEARCH +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SELECTION import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_BIOMETRICS import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_CONFIRMATION @@ -69,6 +72,9 @@ import com.simprints.infra.events.event.domain.models.EventType.SUSPICIOUS_INTEN import com.simprints.infra.events.event.domain.models.EventType.VERO_2_INFO_SNAPSHOT import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent.ExternalCredentialCapturePayload import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent.ExternalCredentialCaptureValuePayload +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationPayload +import com.simprints.infra.events.event.domain.models.ExternalCredentialSearchEvent.ExternalCredentialSearchPayload +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent.ExternalCredentialSelectionPayload import com.simprints.infra.events.event.domain.models.GuidSelectionEvent.GuidSelectionPayload import com.simprints.infra.events.event.domain.models.IntentParsingEvent.IntentParsingPayload import com.simprints.infra.events.event.domain.models.InvalidIntentEvent.InvalidIntentPayload @@ -177,6 +183,9 @@ internal fun EventPayload.fromDomainToApi(): ApiEventPayload = when (this.type) AGE_GROUP_SELECTION -> ApiAgeGroupSelectionPayload(this as AgeGroupSelectionEvent.AgeGroupSelectionPayload) BIOMETRIC_REFERENCE_CREATION -> ApiBiometricReferenceCreationPayload(this as BiometricReferenceCreationPayload) ENROLMENT_UPDATE -> ApiEnrolmentUpdatePayload(this as EnrolmentUpdatePayload) + EXTERNAL_CREDENTIAL_SELECTION -> ApiExternalCredentialSelectionPayload(this as ExternalCredentialSelectionPayload) EXTERNAL_CREDENTIAL_CAPTURE -> ApiExternalCredentialCapturePayload(this as ExternalCredentialCapturePayload) EXTERNAL_CREDENTIAL_CAPTURE_VALUE -> ApiExternalCredentialCaptureValuePayload(this as ExternalCredentialCaptureValuePayload) + EXTERNAL_CREDENTIAL_SEARCH -> ApiExternalCredentialSearchPayload(this as ExternalCredentialSearchPayload) + EXTERNAL_CREDENTIAL_CONFIRMATION -> ApiExternalCredentialConfirmationPayload(this as ExternalCredentialConfirmationPayload) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt index befee5bebb..141a6ad0a5 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt @@ -34,6 +34,9 @@ import com.simprints.infra.events.event.domain.models.EventType.EVENT_DOWN_SYNC_ import com.simprints.infra.events.event.domain.models.EventType.EVENT_UP_SYNC_REQUEST import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CAPTURE import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CAPTURE_VALUE +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_CONFIRMATION +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SEARCH +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SELECTION import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_BIOMETRICS import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_CONFIRMATION @@ -92,8 +95,11 @@ internal enum class ApiEventPayloadType { AgeGroupSelection, BiometricReferenceCreation, EnrolmentUpdate, + ExternalCredentialSelection, ExternalCredentialCaptureValue, ExternalCredentialCapture, + ExternalCredentialSearch, + ExternalCredentialConfirmation, } internal fun EventType.fromDomainToApi(): ApiEventPayloadType = when (this) { @@ -150,8 +156,11 @@ internal fun EventType.fromDomainToApi(): ApiEventPayloadType = when (this) { AGE_GROUP_SELECTION -> ApiEventPayloadType.AgeGroupSelection BIOMETRIC_REFERENCE_CREATION -> ApiEventPayloadType.BiometricReferenceCreation ENROLMENT_UPDATE -> ApiEventPayloadType.EnrolmentUpdate + EXTERNAL_CREDENTIAL_SELECTION -> ApiEventPayloadType.ExternalCredentialSelection EXTERNAL_CREDENTIAL_CAPTURE_VALUE -> ApiEventPayloadType.ExternalCredentialCaptureValue EXTERNAL_CREDENTIAL_CAPTURE -> ApiEventPayloadType.ExternalCredentialCapture + EXTERNAL_CREDENTIAL_SEARCH -> ApiEventPayloadType.ExternalCredentialSearch + EXTERNAL_CREDENTIAL_CONFIRMATION -> ApiEventPayloadType.ExternalCredentialConfirmation } internal fun ApiEventPayloadType.fromApiToDomain(): EventType = when (this) { @@ -190,6 +199,9 @@ internal fun ApiEventPayloadType.fromApiToDomain(): EventType = when (this) { ApiEventPayloadType.Callout -> throw UnsupportedOperationException("") ApiEventPayloadType.Callback -> throw UnsupportedOperationException("") ApiEventPayloadType.EnrolmentUpdate -> ENROLMENT_UPDATE + ApiEventPayloadType.ExternalCredentialSelection -> EXTERNAL_CREDENTIAL_SELECTION ApiEventPayloadType.ExternalCredentialCaptureValue -> EXTERNAL_CREDENTIAL_CAPTURE_VALUE ApiEventPayloadType.ExternalCredentialCapture -> EXTERNAL_CREDENTIAL_CAPTURE + ApiEventPayloadType.ExternalCredentialSearch -> EXTERNAL_CREDENTIAL_SEARCH + ApiEventPayloadType.ExternalCredentialConfirmation -> EXTERNAL_CREDENTIAL_CONFIRMATION } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialCapturePayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialCapturePayload.kt index 3840145d4f..214901dcdc 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialCapturePayload.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialCapturePayload.kt @@ -8,7 +8,7 @@ import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureE internal data class ApiExternalCredentialCapturePayload( override val startTime: ApiTimestamp, val id: String, - val endTime: ApiTimestamp, + val endTime: ApiTimestamp?, val autoCaptureStartTime: ApiTimestamp, val autoCaptureEndTime: ApiTimestamp, val ocrErrorCount: Int, @@ -18,8 +18,8 @@ internal data class ApiExternalCredentialCapturePayload( ) : ApiEventPayload(startTime) { constructor(domainPayload: ExternalCredentialCapturePayload) : this( startTime = domainPayload.createdAt.fromDomainToApi(), + endTime = domainPayload.endedAt?.fromDomainToApi(), id = domainPayload.id, - endTime = domainPayload.endTime.fromDomainToApi(), autoCaptureStartTime = domainPayload.autoCaptureStartTime.fromDomainToApi(), autoCaptureEndTime = domainPayload.autoCaptureEndTime.fromDomainToApi(), ocrErrorCount = domainPayload.ocrErrorCount, diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialConfirmationPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialConfirmationPayload.kt new file mode 100644 index 0000000000..04ec17adad --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialConfirmationPayload.kt @@ -0,0 +1,35 @@ +package com.simprints.infra.eventsync.event.remote.models + +import androidx.annotation.Keep +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationResult +import com.simprints.infra.eventsync.event.remote.models.ApiExternalCredentialConfirmationPayload.ApiExternalCredentialConfirmationResult + +@Keep +internal data class ApiExternalCredentialConfirmationPayload( + override val startTime: ApiTimestamp, + val endTime: ApiTimestamp?, + val result: ApiExternalCredentialConfirmationResult, + val userInteractedWithImage: Boolean? = null, +) : ApiEventPayload(startTime) { + constructor(domainPayload: ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationPayload) : this( + startTime = domainPayload.createdAt.fromDomainToApi(), + endTime = domainPayload.endedAt?.fromDomainToApi(), + result = domainPayload.result.fromDomainToApi(), + userInteractedWithImage = domainPayload.userInteractedWithImage, + ) + + @Keep + enum class ApiExternalCredentialConfirmationResult { + CONTINUE, + RECAPTURE, + } + + override fun getTokenizedFieldJsonPath(tokenKeyType: TokenKeyType): String? = null +} + +internal fun ExternalCredentialConfirmationResult.fromDomainToApi(): ApiExternalCredentialConfirmationResult = when (this) { + ExternalCredentialConfirmationResult.CONTINUE -> ApiExternalCredentialConfirmationResult.CONTINUE + ExternalCredentialConfirmationResult.RECAPTURE -> ApiExternalCredentialConfirmationResult.RECAPTURE +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSearchPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSearchPayload.kt new file mode 100644 index 0000000000..b0b2ae1832 --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSearchPayload.kt @@ -0,0 +1,31 @@ +package com.simprints.infra.eventsync.event.remote.models + +import androidx.annotation.Keep +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.events.event.domain.models.ExternalCredentialSearchEvent + +@Keep +internal data class ApiExternalCredentialSearchPayload( + override val startTime: ApiTimestamp, + val endTime: ApiTimestamp?, + val id: String, + val probeExternalCredentialId: String, + val result: ApiExternalCredentialSearchResult, +) : ApiEventPayload(startTime) { + constructor(domainPayload: ExternalCredentialSearchEvent.ExternalCredentialSearchPayload) : this( + startTime = domainPayload.createdAt.fromDomainToApi(), + endTime = domainPayload.endedAt?.fromDomainToApi(), + id = domainPayload.id, + probeExternalCredentialId = domainPayload.probeExternalCredentialId, + result = ApiExternalCredentialSearchResult( + candidateIds = domainPayload.result.candidateIds, + ), + ) + + @Keep + data class ApiExternalCredentialSearchResult( + val candidateIds: List, + ) + + override fun getTokenizedFieldJsonPath(tokenKeyType: TokenKeyType): String? = null +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSelectionPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSelectionPayload.kt new file mode 100644 index 0000000000..1af14bb9bd --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiExternalCredentialSelectionPayload.kt @@ -0,0 +1,51 @@ +package com.simprints.infra.eventsync.event.remote.models + +import androidx.annotation.Keep +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType +import com.simprints.infra.config.store.remote.models.fromDomainToApi +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent.SkipReason +import com.simprints.infra.eventsync.event.remote.models.ApiExternalCredentialSelectionPayload.ApiExternalCredentialSkipReason + +@Keep +internal data class ApiExternalCredentialSelectionPayload( + override val startTime: ApiTimestamp, + val endTime: ApiTimestamp?, + val id: String, + val credentialType: ApiExternalCredentialType?, + val skipReason: ApiExternalCredentialSkipReason?, + val skipOther: String?, +) : ApiEventPayload(startTime) { + constructor(domainPayload: ExternalCredentialSelectionEvent.ExternalCredentialSelectionPayload) : this( + startTime = domainPayload.createdAt.fromDomainToApi(), + endTime = domainPayload.endedAt?.fromDomainToApi(), + id = domainPayload.id, + credentialType = domainPayload.credentialType?.fromDomainToApi(), + skipReason = domainPayload.skipReason?.toApiExternalCredentialSkipReason(), + skipOther = domainPayload.skipOther, + ) + + @Keep + enum class ApiExternalCredentialSkipReason { + DOES_NOT_HAVE_ID, + DID_NOT_BRING_ID, + BROUGHT_INCORRECT_ID, + NO_CONSENT, + ID_DAMAGED, + UNABLE_TO_SCAN, + OTHER, + } + + override fun getTokenizedFieldJsonPath(tokenKeyType: TokenKeyType): String? = null +} + +internal fun SkipReason.toApiExternalCredentialSkipReason(): ApiExternalCredentialSkipReason = when (this) { + SkipReason.DOES_NOT_HAVE_ID -> ApiExternalCredentialSkipReason.DOES_NOT_HAVE_ID + SkipReason.DID_NOT_BRING_ID -> ApiExternalCredentialSkipReason.DID_NOT_BRING_ID + SkipReason.BROUGHT_INCORRECT_ID -> ApiExternalCredentialSkipReason.BROUGHT_INCORRECT_ID + SkipReason.NO_CONSENT -> ApiExternalCredentialSkipReason.NO_CONSENT + SkipReason.ID_DAMAGED -> ApiExternalCredentialSkipReason.ID_DAMAGED + SkipReason.UNABLE_TO_SCAN -> ApiExternalCredentialSkipReason.UNABLE_TO_SCAN + SkipReason.OTHER -> ApiExternalCredentialSkipReason.OTHER +} diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/subject/ApiExternalCredential.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/subject/ApiExternalCredential.kt index 56e211b037..e49afdded8 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/subject/ApiExternalCredential.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/subject/ApiExternalCredential.kt @@ -4,6 +4,7 @@ import com.simprints.core.domain.externalcredential.ExternalCredential import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.remote.models.ApiExternalCredentialType +import com.simprints.infra.config.store.remote.models.fromDomainToApi data class ApiExternalCredential( val id: String, @@ -21,9 +22,5 @@ internal fun ApiExternalCredential.fromApiToDomain(subjectId: String) = External internal fun ExternalCredential.fromDomainToApi() = ApiExternalCredential( id = id, value = value.value, - type = when (type) { - ExternalCredentialType.NHISCard -> ApiExternalCredentialType.NHIS_CARD - ExternalCredentialType.GhanaIdCard -> ApiExternalCredentialType.GHANA_CARD - ExternalCredentialType.QRCode -> ApiExternalCredentialType.QR_CODE - }, + type = type.fromDomainToApi(), ) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt index 58efb54f81..17f3964b03 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/tasks/EventUpSyncTask.kt @@ -17,7 +17,9 @@ import com.simprints.infra.events.EventRepository import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.EnrolmentEventV2 import com.simprints.infra.events.event.domain.models.EnrolmentEventV4 +import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent import com.simprints.infra.events.event.domain.models.Event +import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent import com.simprints.infra.events.event.domain.models.PersonCreationEvent import com.simprints.infra.events.event.domain.models.face.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.fingerprint.FingerprintCaptureBiometricsEvent @@ -334,11 +336,15 @@ internal class EventUpSyncTask @Inject constructor( it is PersonCreationEvent || it is FingerprintCaptureBiometricsEvent || it is FaceCaptureBiometricsEvent || - it is BiometricReferenceCreationEvent + it is BiometricReferenceCreationEvent || + it is EnrolmentUpdateEvent || + it is ExternalCredentialCaptureValueEvent } config.canSyncAnalyticsDataToSimprints() -> events.filterNot { - it is FingerprintCaptureBiometricsEvent || it is FaceCaptureBiometricsEvent + it is FingerprintCaptureBiometricsEvent || + it is FaceCaptureBiometricsEvent || + it is ExternalCredentialCaptureValueEvent } else -> emptyList() diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt index 5d9bc33e5e..175c1de6ed 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt @@ -734,6 +734,17 @@ fun validateEnrolmentUpdateEventApiModel(json: JSONObject) { } } +fun validateExternalCredentialSelectionEventApiModel(json: JSONObject) { + validateCommonParams(json, "ExternalCredentialSelection", 0) + with(json.getJSONObject("payload")) { + validateTimestamp(getJSONObject("startTime")) + validateTimestamp(getJSONObject("endTime")) + assertThat(getString("id")).isNotNull() + assertThat(getString("skipReason")).isNotNull() + assertThat(getString("skipOther")).isNotNull() + } +} + fun validateExternalCredentialCaptureValueEventApiModel(json: JSONObject) { validateCommonParams(json, "ExternalCredentialCaptureValue", 0) with(json.getJSONObject("payload")) { @@ -758,4 +769,24 @@ fun validateExternalCredentialCaptureEventApiModel(json: JSONObject) { } } +fun validateExternalCredentialSearchApiModel(json: JSONObject) { + validateCommonParams(json, "ExternalCredentialSearch", 0) + with(json.getJSONObject("payload")) { + validateTimestamp(getJSONObject("startTime")) + assertThat(getString("id")).isNotNull() + assertThat(getString("endTime")).isNotNull() + assertThat(getString("probeExternalCredentialId")).isNotNull() + assertThat(getJSONObject("result")).isNotNull() + assertThat(getJSONObject("result").getJSONArray("candidateIds").length()).isGreaterThan(0) + } +} + +fun validateExternalCredentialConfirmationApiModel(json: JSONObject) { + validateCommonParams(json, "ExternalCredentialConfirmation", 0) + with(json.getJSONObject("payload")) { + validateTimestamp(getJSONObject("startTime")) + assertThat(getString("result")).isNotNull() + } +} + private fun Array.valuesAsStrings(): List = this.map { it.toString() } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt index 883d28bfda..15d5550b9b 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventScopeToApiUseCaseTest.kt @@ -9,14 +9,12 @@ import com.simprints.infra.events.event.domain.models.scope.EventScope import com.simprints.infra.events.event.domain.models.scope.EventScopePayload import com.simprints.infra.events.event.domain.models.scope.EventScopeType import com.simprints.infra.eventsync.event.remote.models.ApiEvent -import com.simprints.infra.eventsync.event.remote.models.fromDomainToApi import io.mockk.* import io.mockk.impl.annotations.MockK import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test - internal class MapDomainEventScopeToApiUseCaseTest { @MockK lateinit var mapDomainEventToApiUseCase: MapDomainEventToApiUseCase @@ -36,7 +34,7 @@ internal class MapDomainEventScopeToApiUseCaseTest { fun `should map events using use case`() { val scope = createBlankSessionScope() val events = listOf( - mockk() + mockk(), ) val resultEvent = mockk() every { mapDomainEventToApiUseCase(any(), any()) } returns resultEvent @@ -50,7 +48,7 @@ internal class MapDomainEventScopeToApiUseCaseTest { fun `should map fields correctly`() { val scope = createBlankSessionScope() val events = listOf( - mockk() + mockk(), ) val resultEvent = mockk() every { mapDomainEventToApiUseCase(any(), any()) } returns resultEvent @@ -61,7 +59,6 @@ internal class MapDomainEventScopeToApiUseCaseTest { assertEquals(startTime.unixMs, scope.createdAt.ms) assertEquals(endTime, scope.endedAt) } - } private fun createBlankSessionScope() = EventScope( diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt index 9c7520c990..294f6a4cf5 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt @@ -33,6 +33,9 @@ import com.simprints.infra.events.sampledata.createEventDownSyncRequestEvent import com.simprints.infra.events.sampledata.createEventUpSyncRequestEvent import com.simprints.infra.events.sampledata.createExternalCredentialCaptureEvent import com.simprints.infra.events.sampledata.createExternalCredentialCaptureValueEvent +import com.simprints.infra.events.sampledata.createExternalCredentialConfirmationEvent +import com.simprints.infra.events.sampledata.createExternalCredentialSearchEvent +import com.simprints.infra.events.sampledata.createExternalCredentialSelectionEvent import com.simprints.infra.events.sampledata.createFaceCaptureBiometricsEvent import com.simprints.infra.events.sampledata.createFaceCaptureConfirmationEvent import com.simprints.infra.events.sampledata.createFaceCaptureEvent @@ -64,43 +67,7 @@ import com.simprints.infra.events.sampledata.createVerificationCalloutEventV2 import com.simprints.infra.events.sampledata.createVerificationCalloutEventV3 import com.simprints.infra.events.sampledata.createVero2InfoSnapshotEvent import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.AgeGroupSelection -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.AlertScreen -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Authentication -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Authorization -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.BiometricReferenceCreation -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Callback -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Callout -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.CandidateRead -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.CompletionCheck -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.ConnectivitySnapshot -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Consent -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Enrolment -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.EnrolmentUpdate -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.EventDownSyncRequest -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.EventUpSyncRequest -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.ExternalCredentialCapture -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.ExternalCredentialCaptureValue -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FaceCapture -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FaceCaptureBiometrics -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FaceCaptureConfirmation -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FaceFallbackCapture -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FaceOnboardingComplete -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FingerprintCapture -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.FingerprintCaptureBiometrics -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.GuidSelection -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.IntentParsing -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.InvalidIntent -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.LicenseCheck -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.OneToManyMatch -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.OneToOneMatch -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.PersonCreation -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Refusal -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.SampleUpSyncRequest -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.ScannerConnection -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.ScannerFirmwareUpdate -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.SuspiciousIntent -import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.Vero2InfoSnapshot +import com.simprints.infra.eventsync.event.remote.models.ApiEventPayloadType.* import com.simprints.infra.eventsync.event.validateAgeGroupSelectionEventApiModel import com.simprints.infra.eventsync.event.validateAlertScreenEventApiModel import com.simprints.infra.eventsync.event.validateAuthenticationEventApiModel @@ -121,6 +88,9 @@ import com.simprints.infra.eventsync.event.validateEnrolmentEventV4ApiModel import com.simprints.infra.eventsync.event.validateEnrolmentUpdateEventApiModel import com.simprints.infra.eventsync.event.validateExternalCredentialCaptureEventApiModel import com.simprints.infra.eventsync.event.validateExternalCredentialCaptureValueEventApiModel +import com.simprints.infra.eventsync.event.validateExternalCredentialConfirmationApiModel +import com.simprints.infra.eventsync.event.validateExternalCredentialSearchApiModel +import com.simprints.infra.eventsync.event.validateExternalCredentialSelectionEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureBiometricsEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureConfirmationEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureEventApiModel @@ -614,6 +584,15 @@ internal class MapDomainEventToApiUseCaseTest { validateEnrolmentUpdateEventApiModel(json) } + @Test + fun validate_externalCredentialSelectionEventApiModel() { + val event = createExternalCredentialSelectionEvent() + val apiEvent = useCase(event, project) + val json = JSONObject(jackson.writeValueAsString(apiEvent)) + + validateExternalCredentialSelectionEventApiModel(json) + } + @Test fun validate_externalCredentialCaptureValueEventApiModel() { val event = createExternalCredentialCaptureValueEvent() @@ -632,6 +611,24 @@ internal class MapDomainEventToApiUseCaseTest { validateExternalCredentialCaptureEventApiModel(json) } + @Test + fun validate_externalCredentialSearchEventApiModel() { + val event = createExternalCredentialSearchEvent() + val apiEvent = useCase(event, project) + val json = JSONObject(jackson.writeValueAsString(apiEvent)) + + validateExternalCredentialSearchApiModel(json) + } + + @Test + fun validate_externalCredentialConfirmationEventApiModel() { + val event = createExternalCredentialConfirmationEvent() + val apiEvent = useCase(event, project) + val json = JSONObject(jackson.writeValueAsString(apiEvent)) + + validateExternalCredentialConfirmationApiModel(json) + } + @Test fun `when event contains tokenized attendant id, then ApiEvent should contain tokenizedField`() { validateUserIdTokenization(attendantId = "attendantId".asTokenizableEncrypted()) @@ -734,9 +731,12 @@ internal class MapDomainEventToApiUseCaseTest { LicenseCheck -> validate_licenseCheckEventApiModel() AgeGroupSelection -> validate_ageGroupSelectionEventApiModel() BiometricReferenceCreation -> validate_biometricReferenceCreationEventApiModel() - EnrolmentUpdate -> TODO() - ExternalCredentialCaptureValue -> TODO() - ExternalCredentialCapture -> TODO() + EnrolmentUpdate -> validate_enrolmentUpdateEventApiModel() + ExternalCredentialSelection -> validate_externalCredentialSelectionEventApiModel() + ExternalCredentialCaptureValue -> validate_externalCredentialCaptureValueEventApiModel() + ExternalCredentialCapture -> validate_externalCredentialCaptureEventApiModel() + ExternalCredentialSearch -> validate_externalCredentialSearchEventApiModel() + ExternalCredentialConfirmation -> validate_externalCredentialConfirmationEventApiModel() null -> TODO() }.safeSealedWhens } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt index 4f00c40d0f..0cdbe50524 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt @@ -18,8 +18,9 @@ internal class RemoteTestingHelper { ApiEventPayloadType.FingerprintCaptureBiometrics, ApiEventPayloadType.FaceCaptureBiometrics, ApiEventPayloadType.EventDownSyncRequest, ApiEventPayloadType.EventUpSyncRequest, ApiEventPayloadType.LicenseCheck, ApiEventPayloadType.AgeGroupSelection, ApiEventPayloadType.BiometricReferenceCreation, ApiEventPayloadType.SampleUpSyncRequest, - ApiEventPayloadType.EnrolmentUpdate, ApiEventPayloadType.ExternalCredentialCaptureValue, - ApiEventPayloadType.ExternalCredentialCapture, null, + ApiEventPayloadType.EnrolmentUpdate, ApiEventPayloadType.ExternalCredentialSelection, + ApiEventPayloadType.ExternalCredentialCaptureValue, ApiEventPayloadType.ExternalCredentialCapture, + ApiEventPayloadType.ExternalCredentialSearch, ApiEventPayloadType.ExternalCredentialConfirmation, null, -> { // ADD TEST FOR NEW EVENT IN THIS CLASS } diff --git a/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt b/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt index e609e8ece1..17925d2d93 100644 --- a/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt +++ b/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt @@ -32,6 +32,10 @@ import com.simprints.infra.events.event.domain.models.Event import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent +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.GuidSelectionEvent import com.simprints.infra.events.event.domain.models.IntentParsingEvent @@ -544,15 +548,22 @@ fun createEnrolmentUpdateEvent() = EnrolmentUpdateEvent( externalCredentialIdsToAdd = listOf(CREDENTIAL_ID), ) +fun createExternalCredentialSelectionEvent() = ExternalCredentialSelectionEvent( + createdAt = CREATED_AT, + endedAt = CREATED_AT, + skipReason = ExternalCredentialSelectionEvent.SkipReason.OTHER, + skipOther = DEFAULT_METADATA, +) + fun createExternalCredentialCaptureValueEvent() = ExternalCredentialCaptureValueEvent( createdAt = CREATED_AT, - id = CREDENTIAL_ID, + payloadId = CREDENTIAL_ID, credential = EXTERNAL_CREDENTIAL, ) fun createExternalCredentialCaptureEvent() = ExternalCredentialCaptureEvent( - createdAt = CREATED_AT, - id = CREDENTIAL_ID, + startTime = CREATED_AT, + payloadId = CREDENTIAL_ID, endTime = CREATED_AT, autoCaptureStartTime = CREATED_AT, autoCaptureEndTime = CREATED_AT, @@ -561,3 +572,17 @@ fun createExternalCredentialCaptureEvent() = ExternalCredentialCaptureEvent( credentialTextLength = 0, selectionId = GUID1, ) + +fun createExternalCredentialSearchEvent() = ExternalCredentialSearchEvent( + createdAt = CREATED_AT, + endedAt = CREATED_AT, + probeExternalCredentialId = GUID1, + candidateIds = listOf(GUID1, GUID2), +) + +fun createExternalCredentialConfirmationEvent() = ExternalCredentialConfirmationEvent( + createdAt = CREATED_AT, + endedAt = CREATED_AT, + result = ExternalCredentialConfirmationResult.CONTINUE, + userInteractedWithImage = true, +) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt index c95a9f99c9..0846658873 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt @@ -37,6 +37,9 @@ import com.simprints.infra.events.event.domain.models.EventType.Companion.EVENT_ import com.simprints.infra.events.event.domain.models.EventType.Companion.EVENT_UP_SYNC_REQUEST_KEY import com.simprints.infra.events.event.domain.models.EventType.Companion.EXTERNAL_CREDENTIAL_CAPTURE_KEY import com.simprints.infra.events.event.domain.models.EventType.Companion.EXTERNAL_CREDENTIAL_CAPTURE_VALUE_KEY +import com.simprints.infra.events.event.domain.models.EventType.Companion.EXTERNAL_CREDENTIAL_CONFIRMATION_KEY +import com.simprints.infra.events.event.domain.models.EventType.Companion.EXTERNAL_CREDENTIAL_SEARCH_KEY +import com.simprints.infra.events.event.domain.models.EventType.Companion.EXTERNAL_CREDENTIAL_SELECTION_KEY import com.simprints.infra.events.event.domain.models.EventType.Companion.FACE_CAPTURE_BIOMETRICS_KEY import com.simprints.infra.events.event.domain.models.EventType.Companion.FACE_CAPTURE_CONFIRMATION_KEY import com.simprints.infra.events.event.domain.models.EventType.Companion.FACE_CAPTURE_KEY @@ -141,8 +144,11 @@ import com.simprints.infra.events.event.domain.models.upsync.EventUpSyncRequestE JsonSubTypes.Type(value = BiometricReferenceCreationEvent::class, name = BIOMETRIC_REFERENCE_CREATION_KEY), JsonSubTypes.Type(value = SampleUpSyncRequestEvent::class, name = SAMPLE_UP_SYNC_REQUEST), JsonSubTypes.Type(value = EnrolmentUpdateEvent::class, name = ENROLMENT_UPDATE_KEY), + JsonSubTypes.Type(value = ExternalCredentialSelectionEvent::class, name = EXTERNAL_CREDENTIAL_SELECTION_KEY), JsonSubTypes.Type(value = ExternalCredentialCaptureValueEvent::class, name = EXTERNAL_CREDENTIAL_CAPTURE_VALUE_KEY), JsonSubTypes.Type(value = ExternalCredentialCaptureEvent::class, name = EXTERNAL_CREDENTIAL_CAPTURE_KEY), + JsonSubTypes.Type(value = ExternalCredentialSearchEvent::class, name = EXTERNAL_CREDENTIAL_SEARCH_KEY), + JsonSubTypes.Type(value = ExternalCredentialConfirmationEvent::class, name = EXTERNAL_CREDENTIAL_CONFIRMATION_KEY), ) abstract class Event { abstract val id: String diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventPayload.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventPayload.kt index eb7e9553f8..2ce01dac51 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventPayload.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventPayload.kt @@ -16,6 +16,9 @@ import com.simprints.infra.events.event.domain.models.EnrolmentUpdateEvent.Enrol import com.simprints.infra.events.event.domain.models.EventType.Companion import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureEvent.ExternalCredentialCapturePayload import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureValueEvent.ExternalCredentialCaptureValuePayload +import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.* +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.GuidSelectionEvent.GuidSelectionPayload import com.simprints.infra.events.event.domain.models.IntentParsingEvent.IntentParsingPayload import com.simprints.infra.events.event.domain.models.InvalidIntentEvent.InvalidIntentPayload @@ -119,8 +122,11 @@ import com.simprints.infra.events.event.domain.models.upsync.EventUpSyncRequestE JsonSubTypes.Type(value = BiometricReferenceCreationPayload::class, name = Companion.BIOMETRIC_REFERENCE_CREATION_KEY), JsonSubTypes.Type(value = SampleUpSyncRequestPayload::class, name = Companion.SAMPLE_UP_SYNC_REQUEST), JsonSubTypes.Type(value = EnrolmentUpdatePayload::class, name = Companion.ENROLMENT_UPDATE_KEY), + JsonSubTypes.Type(value = ExternalCredentialSelectionPayload::class, name = Companion.EXTERNAL_CREDENTIAL_SELECTION_KEY), JsonSubTypes.Type(value = ExternalCredentialCaptureValuePayload::class, name = Companion.EXTERNAL_CREDENTIAL_CAPTURE_VALUE_KEY), JsonSubTypes.Type(value = ExternalCredentialCapturePayload::class, name = Companion.EXTERNAL_CREDENTIAL_CAPTURE_KEY), + JsonSubTypes.Type(value = ExternalCredentialSearchPayload::class, name = Companion.EXTERNAL_CREDENTIAL_SEARCH_KEY), + JsonSubTypes.Type(value = ExternalCredentialConfirmationPayload::class, name = Companion.EXTERNAL_CREDENTIAL_CONFIRMATION_KEY), ) abstract class EventPayload { abstract val type: EventType diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt index e456beffae..f40f370c22 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt @@ -161,8 +161,17 @@ enum class EventType { // key added: EXTERNAL_CREDENTIAL_CAPTURE_KEY EXTERNAL_CREDENTIAL_CAPTURE, + // key added: EXTERNAL_CREDENTIAL_SELECTION_KEY + EXTERNAL_CREDENTIAL_SELECTION, + // key added: EXTERNAL_CREDENTIAL_CAPTURE_VALUE_KEY EXTERNAL_CREDENTIAL_CAPTURE_VALUE, + + // key added: EXTERNAL_CREDENTIAL_SEARCH_KEY + EXTERNAL_CREDENTIAL_SEARCH, + + // key added: EXTERNAL_CREDENTIAL_CONFIRMATION_KEY + EXTERNAL_CREDENTIAL_CONFIRMATION, ; companion object { @@ -216,7 +225,10 @@ enum class EventType { const val AGE_GROUP_SELECTION_KEY = "AGE_GROUP_SELECTION" const val BIOMETRIC_REFERENCE_CREATION_KEY = "BIOMETRIC_REFERENCE_CREATION" const val ENROLMENT_UPDATE_KEY = "ENROLMENT_UPDATE" + const val EXTERNAL_CREDENTIAL_SELECTION_KEY = "EXTERNAL_CREDENTIAL_SELECTION" const val EXTERNAL_CREDENTIAL_CAPTURE_KEY = "EXTERNAL_CREDENTIAL_CAPTURE" const val EXTERNAL_CREDENTIAL_CAPTURE_VALUE_KEY = "EXTERNAL_CREDENTIAL_CAPTURE_VALUE" + const val EXTERNAL_CREDENTIAL_SEARCH_KEY = "EXTERNAL_CREDENTIAL_SEARCH" + const val EXTERNAL_CREDENTIAL_CONFIRMATION_KEY = "EXTERNAL_CREDENTIAL_CONFIRMATION" } } diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureEvent.kt index ad436865cb..5701decf3f 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureEvent.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureEvent.kt @@ -18,9 +18,9 @@ data class ExternalCredentialCaptureEvent( override var projectId: String? = null, ) : Event() { constructor( - createdAt: Timestamp, - id: String, + startTime: Timestamp, endTime: Timestamp, + payloadId: String, autoCaptureStartTime: Timestamp, autoCaptureEndTime: Timestamp, ocrErrorCount: Int, @@ -30,10 +30,10 @@ data class ExternalCredentialCaptureEvent( ) : this( id = UUID.randomUUID().toString(), payload = ExternalCredentialCapturePayload( - createdAt = createdAt, + createdAt = startTime, eventVersion = EVENT_VERSION, - id = id, - endTime = endTime, + id = payloadId, + endedAt = endTime, autoCaptureStartTime = autoCaptureStartTime, autoCaptureEndTime = autoCaptureEndTime, ocrErrorCount = ocrErrorCount, @@ -54,7 +54,6 @@ data class ExternalCredentialCaptureEvent( override val createdAt: Timestamp, override val eventVersion: Int, val id: String, - val endTime: Timestamp, val autoCaptureStartTime: Timestamp, val autoCaptureEndTime: Timestamp, val ocrErrorCount: Int, @@ -64,7 +63,8 @@ data class ExternalCredentialCaptureEvent( override val endedAt: Timestamp? = null, override val type: EventType = EXTERNAL_CREDENTIAL_CAPTURE, ) : EventPayload() { - override fun toSafeString(): String = "credential capture: $id" + override fun toSafeString(): String = "capture ID: $id, ocrErrors: $ocrErrorCount, captured text length: $capturedTextLength" + + "credential length: $credentialTextLength, selection id: $selectionId" } companion object { diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureValueEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureValueEvent.kt index dbd50cce3c..14634e64e0 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureValueEvent.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialCaptureValueEvent.kt @@ -20,14 +20,14 @@ data class ExternalCredentialCaptureValueEvent( ) : Event() { constructor( createdAt: Timestamp, - id: String, + payloadId: String, credential: ExternalCredential, ) : this( id = UUID.randomUUID().toString(), payload = ExternalCredentialCaptureValuePayload( createdAt = createdAt, eventVersion = EVENT_VERSION, - id = id, + id = payloadId, credential = credential, ), type = EXTERNAL_CREDENTIAL_CAPTURE_VALUE, diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialConfirmationEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialConfirmationEvent.kt new file mode 100644 index 0000000000..09b4b0cc97 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialConfirmationEvent.kt @@ -0,0 +1,59 @@ +package com.simprints.infra.events.event.domain.models + +import androidx.annotation.Keep +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.Timestamp +import com.simprints.core.tools.utils.randomUUID +import com.simprints.infra.config.store.models.TokenKeyType + +@Keep +data class ExternalCredentialConfirmationEvent( + override val id: String = randomUUID(), + override val payload: ExternalCredentialConfirmationPayload, + override val type: EventType, + override var scopeId: String? = null, + override var projectId: String? = null, +) : Event() { + constructor( + createdAt: Timestamp, + endedAt: Timestamp, + result: ExternalCredentialConfirmationResult, + userInteractedWithImage: Boolean? = null, + ) : this( + id = randomUUID(), + payload = ExternalCredentialConfirmationPayload( + createdAt = createdAt, + endedAt = endedAt, + eventVersion = EVENT_VERSION, + result = result, + userInteractedWithImage = userInteractedWithImage, + ), + type = EventType.EXTERNAL_CREDENTIAL_CONFIRMATION, + ) + + @Keep + data class ExternalCredentialConfirmationPayload( + override val createdAt: Timestamp, + override val endedAt: Timestamp? = null, + override val eventVersion: Int, + val result: ExternalCredentialConfirmationResult, + val userInteractedWithImage: Boolean?, + override val type: EventType = EventType.EXTERNAL_CREDENTIAL_CONFIRMATION, + ) : EventPayload() { + override fun toSafeString(): String = "result: $result" + } + + @Keep + enum class ExternalCredentialConfirmationResult { + CONTINUE, + RECAPTURE, + } + + override fun getTokenizableFields(): Map = emptyMap() + + override fun setTokenizedFields(map: Map) = this // No tokenized field + + companion object { + const val EVENT_VERSION = 0 + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSearchEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSearchEvent.kt new file mode 100644 index 0000000000..f5f30a1130 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSearchEvent.kt @@ -0,0 +1,64 @@ +package com.simprints.infra.events.event.domain.models + +import androidx.annotation.Keep +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.Timestamp +import com.simprints.core.tools.utils.randomUUID +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SEARCH + +@Keep +data class ExternalCredentialSearchEvent( + override val id: String = randomUUID(), + override val payload: ExternalCredentialSearchPayload, + override val type: EventType, + override var scopeId: String? = null, + override var projectId: String? = null, +) : Event() { + constructor( + id: String = randomUUID(), + createdAt: Timestamp, + endedAt: Timestamp, + probeExternalCredentialId: String, + candidateIds: List, + ) : this( + id = id, + payload = ExternalCredentialSearchPayload( + id = id, + createdAt = createdAt, + endedAt = endedAt, + eventVersion = EVENT_VERSION, + probeExternalCredentialId = probeExternalCredentialId, + result = ExternalCredentialSearchResult( + candidateIds = candidateIds, + ), + ), + type = EXTERNAL_CREDENTIAL_SEARCH, + ) + + @Keep + data class ExternalCredentialSearchPayload( + override val createdAt: Timestamp, + override val endedAt: Timestamp? = null, + override val eventVersion: Int, + val id: String, + val probeExternalCredentialId: String, + val result: ExternalCredentialSearchResult, + override val type: EventType = EXTERNAL_CREDENTIAL_SEARCH, + ) : EventPayload() { + override fun toSafeString(): String = "results: $result, probe ID: $probeExternalCredentialId" + } + + @Keep + data class ExternalCredentialSearchResult( + val candidateIds: List, + ) + + override fun getTokenizableFields(): Map = emptyMap() + + override fun setTokenizedFields(map: Map) = this // No tokenized field + + companion object { + const val EVENT_VERSION = 0 + } +} diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSelectionEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSelectionEvent.kt new file mode 100644 index 0000000000..60e0efa838 --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/ExternalCredentialSelectionEvent.kt @@ -0,0 +1,96 @@ +package com.simprints.infra.events.event.domain.models + +import androidx.annotation.Keep +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.infra.config.store.models.TokenKeyType + +@Keep +data class ExternalCredentialSelectionEvent( + override val id: String = randomUUID(), + override val payload: ExternalCredentialSelectionPayload, + override val type: EventType, + override var scopeId: String? = null, + override var projectId: String? = null, +) : Event() { + constructor( + createdAt: Timestamp, + endedAt: Timestamp, + credentialType: ExternalCredentialType, + ) : this( + createdAt = createdAt, + endedAt = endedAt, + credentialType = credentialType, + skipReason = null, + skipOther = null, + ) + + constructor( + createdAt: Timestamp, + endedAt: Timestamp, + skipReason: SkipReason, + skipOther: String?, + ) : this( + createdAt = createdAt, + endedAt = endedAt, + credentialType = null, + skipReason = skipReason, + skipOther = skipOther.takeIf { skipReason == SkipReason.OTHER }, + ) + + constructor( + id: String = randomUUID(), + createdAt: Timestamp, + endedAt: Timestamp, + credentialType: ExternalCredentialType?, + skipReason: SkipReason?, + skipOther: String?, + ) : this( + id = id, + payload = ExternalCredentialSelectionPayload( + createdAt = createdAt, + endedAt = endedAt, + eventVersion = EVENT_VERSION, + id = id, + credentialType = credentialType, + skipReason = skipReason, + skipOther = skipOther, + ), + type = EventType.EXTERNAL_CREDENTIAL_SELECTION, + ) + + @Keep + data class ExternalCredentialSelectionPayload( + override val createdAt: Timestamp, + override val endedAt: Timestamp? = null, + override val eventVersion: Int, + val id: String, + val credentialType: ExternalCredentialType?, + val skipReason: SkipReason?, + val skipOther: String?, + override val type: EventType = EventType.EXTERNAL_CREDENTIAL_SELECTION, + ) : EventPayload() { + override fun toSafeString(): String = "credentialType: $credentialType, skipReason: $skipReason, skipOther: $skipOther" + } + + @Keep + enum class SkipReason { + DOES_NOT_HAVE_ID, + DID_NOT_BRING_ID, + BROUGHT_INCORRECT_ID, + NO_CONSENT, + ID_DAMAGED, + UNABLE_TO_SCAN, + OTHER, + } + + override fun getTokenizableFields(): Map = emptyMap() + + override fun setTokenizedFields(map: Map) = this // No tokenized field + + companion object { + const val EVENT_VERSION = 0 + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt index 8410831378..13f3e1e9f1 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/migrations/EventMigrationTest.kt @@ -47,7 +47,7 @@ class EventMigrationTest { } close() } - val db = helper.runMigrationsAndValidate(TEST_DB, 14, true, *ALL_MIGRATIONS) + val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, *ALL_MIGRATIONS) db.query("SELECT * FROM $TABLE_NAME").use { cursor -> while (cursor.moveToNext()) { val eventJson = cursor.getStringWithColumnName("eventJson")!! @@ -112,6 +112,7 @@ class EventMigrationTest { EventMigration12to13(), EventMigration13to14(), EventMigration14to15(), + EventMigration15to16(), ) val tokenizeSerializationModule = SimpleModule().apply { addSerializer(TokenizableString::class.java, TokenizationClassNameSerializer()) diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt index e210fda3c2..886e5b7868 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt @@ -320,4 +320,44 @@ class DbEventTest { assertThat(original).isEqualTo(transformed) } + + @Test + fun convert_ExternalCredentialSelectionEvent() { + val original = createExternalCredentialSelectionEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + } + + @Test + fun convert_ExternalCredentialCaptureEvent() { + val original = createExternalCredentialCaptureEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + } + + @Test + fun convert_ExternalCredentialCaptureValueEvent() { + val original = createExternalCredentialCaptureValueEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + } + + @Test + fun convert_ExternalCredentialSearchEvent() { + val original = createExternalCredentialSearchEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + } + + @Test + fun convert_ExternalCredentialConfirmationEvent() { + val original = createExternalCredentialConfirmationEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + } } diff --git a/infra/events/src/test/resources/all-events/external_credential_capture_v0.json b/infra/events/src/test/resources/all-events/external_credential_capture_v0.json index c9c7d77975..f976af861e 100644 --- a/infra/events/src/test/resources/all-events/external_credential_capture_v0.json +++ b/infra/events/src/test/resources/all-events/external_credential_capture_v0.json @@ -5,11 +5,6 @@ "type": "EXTERNAL_CREDENTIAL_CAPTURE", "id": "dbc27bdb-cab3-463e-9004-68065e05f8ab", "createdAt": 178967890, - "endTime": { - "ms": 178967891, - "isTrustworthy": false, - "msSinceBoot": null - }, "autoCaptureStartTime": { "ms": 178967890, "isTrustworthy": false, diff --git a/infra/events/src/test/resources/all-events/external_credential_confirmation_v0.json b/infra/events/src/test/resources/all-events/external_credential_confirmation_v0.json new file mode 100644 index 0000000000..812e515b67 --- /dev/null +++ b/infra/events/src/test/resources/all-events/external_credential_confirmation_v0.json @@ -0,0 +1,12 @@ +{ + "id": "0c78469d-15fe-4a65-8e0c-698f996ed115", + "type": "EXTERNAL_CREDENTIAL_CONFIRMATION", + "payload": { + "type": "EXTERNAL_CREDENTIAL_CONFIRMATION", + "id": "dbc27bdb-cab3-463e-9004-68065e05f8ab", + "createdAt": 178967890, + "endedAt": 178967891, + "result": "CONTINUE", + "userInteractedWithImage": true + } +} diff --git a/infra/events/src/test/resources/all-events/external_credential_search_v0.json b/infra/events/src/test/resources/all-events/external_credential_search_v0.json new file mode 100644 index 0000000000..62e22f9f24 --- /dev/null +++ b/infra/events/src/test/resources/all-events/external_credential_search_v0.json @@ -0,0 +1,17 @@ +{ + "id": "18baadd9-e67f-44af-bc04-de3279591f1f", + "type": "EXTERNAL_CREDENTIAL_SEARCH", + "payload": { + "type": "EXTERNAL_CREDENTIAL_SEARCH", + "id": "dbc27bdb-cab3-463e-9004-68065e05f8ab", + "createdAt": 178967890, + "endedAt": 178967891, + "result": { + "candidateIds": [ + "c12345a9-4b6d-4a3f-bb5c-82f7f9a6d2c1", + "a98765c2-1234-4ef8-9abc-3e8a5f1b2e77" + ] + }, + "probeExternalCredentialId": "e1f5c9d7-3a6b-47c9-bd6a-8f1c2e4a5b33" + } +} diff --git a/infra/events/src/test/resources/all-events/external_credential_selection_v0.json b/infra/events/src/test/resources/all-events/external_credential_selection_v0.json new file mode 100644 index 0000000000..bda4c2715a --- /dev/null +++ b/infra/events/src/test/resources/all-events/external_credential_selection_v0.json @@ -0,0 +1,21 @@ +{ + "id": "2aae55d8-e679-49bb-99d4-53c13792a31c", + "type": "EXTERNAL_CREDENTIAL_SELECTION", + "payload": { + "type": "EXTERNAL_CREDENTIAL_SELECTION", + "id": "2aae55d8-e679-49bb-99d4-53c13792a31c", + "createdAt": 178967890, + "autoCaptureStartTime": { + "ms": 178967890, + "isTrustworthy": false, + "msSinceBoot": null + }, + "autoCaptureEndTime": { + "ms": 178967891, + "isTrustworthy": false, + "msSinceBoot": null + }, + "endedAt": 178967891, + "skipReason": "NO_CONSENT" + } +}