From 460137160e83a6c72b4f612d27743b6352fb2694 Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:34:10 +0000 Subject: [PATCH 1/7] [MS-1341] Adding on tap focus for the OCR scanner (#1581) * [MS-1341] Adding on tap focus for the OCR scanner * [MS-1341] Removing 'camera' field and verifying lifecycle state before using binding (cherry picked from commit 9c1e0174f6a2220e4b8cdd7088e54e9bb26f7fad) --- .../scanocr/ExternalCredentialScanOcrFragment.kt | 12 ++++++++++++ .../scanocr/usecase/ProvideCameraListenerUseCase.kt | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt index e09a0240f6..62285e6ed5 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt @@ -19,6 +19,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -38,6 +39,7 @@ import com.simprints.feature.externalcredential.screens.scanocr.usecase.ProvideC import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.camera.qrscan.CameraFocusManager import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.view.fadeIn @@ -97,6 +99,9 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex @Inject lateinit var provideCameraListenerUseCase: ProvideCameraListenerUseCase + @Inject + lateinit var cameraFocusManagerFactory: CameraFocusManager.Factory + @Inject @DispatcherBG lateinit var bgDispatcher: CoroutineDispatcher @@ -197,6 +202,13 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex onImageCaptureReady = { capture -> imageCapture = capture }, + onCameraReady = { camera -> + if (lifecycle.currentState == Lifecycle.State.RESUMED) { + val cameraFocusManager = cameraFocusManagerFactory.create(MULTI_FACTOR_ID) + cameraFocusManager.setUpFocusOnTap(binding.preview, camera) + cameraFocusManager.setUpAutoFocus(binding.preview, camera) + } + }, ) cameraProviderFuture.addListener(cameraListener, ContextCompat.getMainExecutor(requireContext())) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt index 8e0f1c09aa..fb9d8ceb22 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt @@ -1,6 +1,7 @@ package com.simprints.feature.externalcredential.screens.scanocr.usecase import androidx.camera.core.AspectRatio +import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture @@ -21,6 +22,7 @@ internal class ProvideCameraListenerUseCase @Inject constructor() { viewLifecycleOwner: LifecycleOwner, onImageAnalysisReady: (ImageAnalysis) -> Unit, onImageCaptureReady: (ImageCapture) -> Unit, + onCameraReady: (Camera) -> Unit, ) = Runnable { val cameraProvider = cameraProviderFuture.get() val aspectRatio = AspectRatio.RATIO_16_9 @@ -48,9 +50,10 @@ internal class ProvideCameraListenerUseCase @Inject constructor() { try { cameraProvider.unbindAll() - cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis) + val camera = cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis) onImageAnalysisReady(imageAnalysis) onImageCaptureReady(imageCapture) + onCameraReady(camera) } catch (e: Exception) { Simber.e("Camera binding failed in OCR", e, MULTI_FACTOR_ID) } From 086ab1b6d1c3caa3417d04311267459ec99458c9 Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:58:08 +0200 Subject: [PATCH 2/7] [MS-1338] MFID: removing the post-match confirmation checkbox (#1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cherry picking latest linter rulesˆ from the main branch * [MS-1338] Removing checkbox from the credential confirmation (cherry picked from commit 376ca5db6ad0056b6b7bfb2b8205179d1792efe4) --- .../view/ScannedCredentialDialog.kt | 5 ----- .../res/layout/dialog_scanned_credential.xml | 18 ++++++------------ 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt index 9fd5837eff..ad996aef1a 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/ScannedCredentialDialog.kt @@ -52,11 +52,6 @@ class ScannedCredentialDialog( documentField.text = credentialField title.text = context.getString(IDR.string.mfid_add_document_title, credential) credentialValue.text = displayedCredential.value - confirmCredentialCheckbox.text = - context.getString(IDR.string.mfid_confirmation_checkbox_text, credentialField) - confirmCredentialCheckbox.setOnCheckedChangeListener { _, isChecked -> - buttonConfirm.isEnabled = isChecked - } buttonSkip.setOnClickListener { onSkip() } buttonConfirm.setOnClickListener { onConfirm() } } diff --git a/feature/external-credential/src/main/res/layout/dialog_scanned_credential.xml b/feature/external-credential/src/main/res/layout/dialog_scanned_credential.xml index 5a1ec274d8..4ee42c7014 100644 --- a/feature/external-credential/src/main/res/layout/dialog_scanned_credential.xml +++ b/feature/external-credential/src/main/res/layout/dialog_scanned_credential.xml @@ -49,22 +49,17 @@ style="@style/Text.Headline4.Bold" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/margin_huge" android:layout_marginEnd="@dimen/margin_large" android:paddingHorizontal="@dimen/margin_large" android:textColor="@color/simprints_text_black" tools:text="GHA-123456789-0" /> - + Date: Tue, 17 Feb 2026 12:49:16 +0200 Subject: [PATCH 3/7] [MS-1339] MFID: add `HAS_NUMBER_NO_ID` skip reason (#1592) * [MS-1339] Adding 'Has number, no card (booklet)' skip reason for MFID * [MS-1339] Adding `HAS_NUMBER_NO_ID` credential skip reason (cherry picked from commit cefefb6e0c73b8c53c96d9e9c73012149c4637d6) --- .../screens/skip/ExternalCredentialSkipFragment.kt | 6 +++++- .../main/res/layout/fragment_external_credential_skip.xml | 7 +++++++ .../remote/models/ApiExternalCredentialSelectionPayload.kt | 2 ++ .../domain/models/ExternalCredentialSelectionEvent.kt | 1 + infra/resources/src/main/res/values/strings.xml | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) 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 e0e7675cc3..fe36111d32 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 @@ -42,6 +42,7 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede private fun initViews(credentialTypes: List) = with(binding) { mapOf( title to IDR.string.mfid_skip_title, + skipReasonHasNumberNoId to IDR.string.mfid_skip_reason_has_number_no_id, 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, @@ -67,7 +68,9 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede reasonTextInput.text.toString().isNotEmpty() } - else -> true + else -> { + true + } } buttonSkip.isEnabled = isSkipButtonEnabled } @@ -95,6 +98,7 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede } private fun viewIdToOption(checkedId: Int) = when (checkedId) { + R.id.skipReasonHasNumberNoId -> ExternalCredentialSelectionEvent.SkipReason.HAS_NUMBER_NO_ID 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 diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml index 25d8233497..38ff3e0a18 100644 --- a/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml +++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_skip.xml @@ -53,6 +53,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> + + ApiExternalCredentialSkipReason.HAS_NUMBER_NO_ID 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 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 index 8781c75588..6da1c5ef8b 100644 --- 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 @@ -84,6 +84,7 @@ data class ExternalCredentialSelectionEvent( @Keep @Serializable enum class SkipReason { + HAS_NUMBER_NO_ID, DOES_NOT_HAVE_ID, DID_NOT_BRING_ID, BROUGHT_INCORRECT_ID, diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index bdbf1ccd5d..2e949b63ec 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -530,6 +530,7 @@ Add this %1$s? Why did you skip the %1$s scan? + Has number, no %1$s (Booklet) Does not have %1$s Did not bring %1$s Brought incorrect %1$s From d00f03b49bdf7fee469b75c127898d5b91d50038 Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:49:43 +0200 Subject: [PATCH 4/7] [MS-1351] Adding system bar insets for `ExternalCredentialControllerFragment` (#1593) * [MS-1351] Adding system bar insets for ExternalCredentialControllerFragment * [MS-1351] Adding system bar insets for fragments in the MFID workflow (cherry picked from commit 655d362809a13a66bfd951df241546a5eec7326c) --- .../screens/controller/ExternalCredentialControllerFragment.kt | 1 - .../screens/search/ExternalCredentialSearchFragment.kt | 2 ++ .../screens/skip/ExternalCredentialSkipFragment.kt | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt index 79a667b186..e674d154f9 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -39,7 +39,6 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - viewModel.init(params) findNavController().handleResult( 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 9df99a4d9b..330cfff174 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 @@ -31,6 +31,7 @@ import com.simprints.feature.externalcredential.screens.search.model.SearchState import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -65,6 +66,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) + applySystemBarInsets(view) initObservers() } 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 fe36111d32..d066dec19e 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 @@ -14,6 +14,7 @@ import com.simprints.feature.externalcredential.databinding.FragmentExternalCred 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.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @@ -28,7 +29,7 @@ class ExternalCredentialSkipFragment : Fragment(R.layout.fragment_external_crede savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - + applySystemBarInsets(view) initObservers() } From 71789d0786a529952ed2ff36c3c07c701ffe072f Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:50:00 +0200 Subject: [PATCH 5/7] [MS-1340] MFID: adding validation for manual credential entry (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cherry picking latest linter rulesˆ from the main branch * [MS-1340] Adding validation for the credential fields edit text * [MS-1340] Updating tests * [MS-1340] Removing existing text watch when rendering updated state * [MS-1340] Fixing code comments * [MS-1340] Confirmation checkbox is disabled if credential is being edited * [MS-1340] Renaming credential validation use cases for better readability (cherry picked from commit 295763e141852f2505be903798e771917db12b89) --- .../usecase/GhanaIdCardOcrSelectorUseCase.kt | 2 +- .../ExternalCredentialSearchFragment.kt | 40 ++++++++++--- .../ExternalCredentialSearchViewModel.kt | 26 ++++++++ .../search/model/SearchCredentialState.kt | 3 + .../src/main/res/color/checkbox_color.xml | 5 ++ .../src/main/res/color/ic_edit_color.xml | 5 ++ .../src/main/res/drawable/ic_done.xml | 2 +- .../fragment_external_credential_search.xml | 2 +- .../ExternalCredentialSearchViewModelTest.kt | 60 +++++++++++++++++++ 9 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 feature/external-credential/src/main/res/color/checkbox_color.xml create mode 100644 feature/external-credential/src/main/res/color/ic_edit_color.xml diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt index b5672f17fa..5c6b74f3d2 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt @@ -6,7 +6,7 @@ internal class GhanaIdCardOcrSelectorUseCase @Inject constructor() { operator fun invoke(readoutValue: String): Boolean = GHANA_ID_PATTERN.matches(readoutValue) companion object { - // Ghana ID card number pattern is "GHA-12345789-0" + // Ghana ID card number pattern is "GHA-123456789-0" private val GHANA_ID_PATTERN = Regex("^GHA-\\d{9}-\\d$") } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 330cfff174..2bc007c62c 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 @@ -3,10 +3,12 @@ package com.simprints.feature.externalcredential.screens.search import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle +import android.text.TextWatcher import android.view.View import android.view.inputmethod.InputMethodManager import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -59,7 +61,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext @Inject lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase - private var isEditingCredential: Boolean = false + private var credentialTextWatcher: TextWatcher? = null override fun onViewCreated( view: View, @@ -94,7 +96,9 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext val credentialType = state.scannedCredential.credentialType val credentialField = resources.getCredentialFieldTitle(credentialType) val currentEditTextValue = credentialEditText.text.toString() + val isEditingCredential = state.isEditingCredential renderImage(state.scannedCredential) + renderCredentialEdit(state) credential.takeIf { currentEditTextValue.isEmpty() }?.let { credentialEditText.setText(it) // Setting only once at the start } @@ -104,18 +108,25 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext credentialValue.text = currentEditTextValue confirmCredentialCheckbox.isVisible = state.searchState != SearchState.Searching confirmCredentialCheckbox.text = getString(IDR.string.mfid_confirmation_checkbox_text, credentialField) - confirmCredentialCheckbox.isChecked = state.isConfirmed + confirmCredentialCheckbox.isChecked = state.isConfirmed && !state.isEditingCredential + confirmCredentialCheckbox.isEnabled = !state.isEditingCredential iconEditCredential.setOnClickListener { - viewModel.updateConfirmation(isConfirmed = false) - toggleCredentialEdit() - if (!isEditingCredential) { - viewModel.confirmCredentialUpdate(credentialEditText.text.toString().asTokenizableRaw()) + if (isEditingCredential) { + viewModel.confirmCredentialUpdate(updatedCredential = credentialEditText.text.toString().asTokenizableRaw()) } + viewModel.updateIsEditingCredential(isEditing = !isEditingCredential) } confirmCredentialCheckbox.setOnCheckedChangeListener { _, checkedId -> viewModel.updateConfirmation(isConfirmed = checkedId) } + + credentialTextWatcher?.let(credentialEditText::removeTextChangedListener) + credentialTextWatcher = credentialEditText.addTextChangedListener( + afterTextChanged = { _ -> + renderEditIcon(isEditingCredential) + }, + ) } private fun renderSearchProgress( @@ -198,7 +209,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext val isSearching = state.searchState != SearchState.Searching buttonRecapture.isVisible = isSearching buttonConfirm.isVisible = isSearching - buttonConfirm.isEnabled = state.isConfirmed + buttonConfirm.isEnabled = state.isConfirmed && !state.isEditingCredential viewModel.getButtonTextResource(state.searchState, state.flowType)?.run(buttonConfirm::setText) buttonConfirm.setOnClickListener { viewModel.finish(state) @@ -226,8 +237,9 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext } } - private fun toggleCredentialEdit() = with(binding) { - isEditingCredential = !isEditingCredential + private fun renderCredentialEdit(state: SearchCredentialState) = with(binding) { + val isEditingCredential = state.isEditingCredential + renderEditIcon(isEditingCredential) val iconRes = if (isEditingCredential) { R.drawable.ic_done } else { @@ -250,4 +262,14 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext private fun hideKeyboard() { requireActivity().hideKeyboard() } + + private fun renderEditIcon(isEditingCredential: Boolean) = with(binding) { + val isEditIconEnabled = if (isEditingCredential) { + viewModel.isCredentialFormatValid(credentialEditText.text?.toString()) + } else { + true + } + iconEditCredential.alpha = if (isEditIconEnabled) 1.0f else 0.5f + iconEditCredential.isEnabled = isEditIconEnabled + } } 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 cbc921aaa9..e3bb78b393 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 @@ -13,6 +13,8 @@ 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.scanocr.usecase.GhanaIdCardOcrSelectorUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase 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 @@ -40,6 +42,8 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( private val tokenizationProcessor: TokenizationProcessor, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val eventsTracker: ExternalCredentialEventTrackerUseCase, + private val ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase, + private val ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase, ) : ViewModel() { @AssistedFactory interface Factory { @@ -80,6 +84,10 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( updateState { it.copy(isConfirmed = isConfirmed) } } + fun updateIsEditingCredential(isEditing: Boolean) { + updateState { it.copy(isEditingCredential = isEditing) } + } + fun confirmCredentialUpdate(updatedCredential: TokenizableString.Raw) { viewModelScope.launch { configRepository.getProject()?.let { project -> @@ -206,4 +214,22 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor( ) } } + + fun isCredentialFormatValid(credential: String?): Boolean { + if (credential == null) return false + return when (scannedCredential.credentialType) { + ExternalCredentialType.NHISCard -> { + // 8 digits + ghanaNhisCardValidationUseCase(credential) + } + ExternalCredentialType.GhanaIdCard -> { + // Ghana ID card number pattern is "GHA-123456789-0" + ghanaIdValidationUseCase(credential) + } + ExternalCredentialType.QRCode -> { + // No QR code validation as of 2025.4.1 + true + } + } + } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt index 4493365467..fee2e741da 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt @@ -5,6 +5,7 @@ import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.model.CredentialMatch +import kotlin.Boolean @Keep @ExcludedFromGeneratedTestCoverageReports("Data struct") @@ -14,6 +15,7 @@ internal data class SearchCredentialState( val flowType: FlowType, val searchState: SearchState, val isConfirmed: Boolean, + val isEditingCredential: Boolean, ) { companion object { fun buildInitial( @@ -25,6 +27,7 @@ internal data class SearchCredentialState( flowType = flowType, searchState = SearchState.Searching, isConfirmed = false, + isEditingCredential = false, ) } } diff --git a/feature/external-credential/src/main/res/color/checkbox_color.xml b/feature/external-credential/src/main/res/color/checkbox_color.xml new file mode 100644 index 0000000000..2f81cf52fc --- /dev/null +++ b/feature/external-credential/src/main/res/color/checkbox_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/external-credential/src/main/res/color/ic_edit_color.xml b/feature/external-credential/src/main/res/color/ic_edit_color.xml new file mode 100644 index 0000000000..982d0cb05b --- /dev/null +++ b/feature/external-credential/src/main/res/color/ic_edit_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/external-credential/src/main/res/drawable/ic_done.xml b/feature/external-credential/src/main/res/drawable/ic_done.xml index c7ea9bc53a..6b7bc1618e 100644 --- a/feature/external-credential/src/main/res/drawable/ic_done.xml +++ b/feature/external-credential/src/main/res/drawable/ic_done.xml @@ -4,6 +4,6 @@ android:viewportWidth="960" android:viewportHeight="960"> diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml index 96b349c4a5..3675099657 100644 --- a/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml +++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml @@ -109,7 +109,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="10dp" android:layout_marginBottom="@dimen/margin_large" - android:buttonTint="@color/simprints_blue" + android:buttonTint="@color/checkbox_color" android:checked="false" android:paddingStart="@dimen/margin_default" android:visibility="gone" 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 cf905cfc3c..49ebabf282 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 @@ -12,6 +12,8 @@ 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.scanocr.usecase.GhanaIdCardOcrSelectorUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase 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 @@ -75,6 +77,12 @@ internal class ExternalCredentialSearchViewModelTest { @MockK lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase + @MockK + lateinit var ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase + + @MockK + lateinit var ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase + private lateinit var viewModel: ExternalCredentialSearchViewModel @Before @@ -96,6 +104,8 @@ internal class ExternalCredentialSearchViewModelTest { tokenizationProcessor = tokenizationProcessor, enrolmentRecordRepository = enrolmentRecordRepository, eventsTracker = eventsTracker, + ghanaIdValidationUseCase = ghanaIdValidationUseCase, + ghanaNhisCardValidationUseCase = ghanaNhisCardValidationUseCase, ) @Test @@ -314,4 +324,54 @@ internal class ExternalCredentialSearchViewModelTest { assertThat(viewModel.stateLiveData.value?.displayedCredential).isEqualTo(decryptedCredential) coVerify { tokenizationProcessor.decrypt(encryptedCredential, TokenKeyType.ExternalCredential, project) } } + + @Test + fun `isCredentialFormatValid validates NHIS card format`() = runTest { + val validNhisCard = "12345678" + val invalidNhisCard = "invalid" + + every { mockScannedCredential.credentialType } returns ExternalCredentialType.NHISCard + every { ghanaNhisCardValidationUseCase(validNhisCard) } returns true + every { ghanaNhisCardValidationUseCase(invalidNhisCard) } returns false + + viewModel = createViewModel() + + assertThat(viewModel.isCredentialFormatValid(validNhisCard)).isTrue() + assertThat(viewModel.isCredentialFormatValid(invalidNhisCard)).isFalse() + assertThat(viewModel.isCredentialFormatValid(null)).isFalse() + verify { ghanaNhisCardValidationUseCase(validNhisCard) } + verify { ghanaNhisCardValidationUseCase(invalidNhisCard) } + } + + @Test + fun `isCredentialFormatValid validates Ghana ID card format`() = runTest { + val validGhanaIdCard = "GHA-12345789-0" + val invalidGhanaIdCard = "invalid" + + every { mockScannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard + every { ghanaIdValidationUseCase(validGhanaIdCard) } returns true + every { ghanaIdValidationUseCase(invalidGhanaIdCard) } returns false + + viewModel = createViewModel() + + assertThat(viewModel.isCredentialFormatValid(validGhanaIdCard)).isTrue() + assertThat(viewModel.isCredentialFormatValid(invalidGhanaIdCard)).isFalse() + assertThat(viewModel.isCredentialFormatValid(null)).isFalse() + verify { ghanaIdValidationUseCase(validGhanaIdCard) } + verify { ghanaIdValidationUseCase(invalidGhanaIdCard) } + } + + @Test + fun `isCredentialFormatValid always returns true for QR code`() = runTest { + val anyValue = "any_value" + val emptyValue = "" + + every { mockScannedCredential.credentialType } returns ExternalCredentialType.QRCode + + viewModel = createViewModel() + + assertThat(viewModel.isCredentialFormatValid(anyValue)).isTrue() + assertThat(viewModel.isCredentialFormatValid(emptyValue)).isTrue() + assertThat(viewModel.isCredentialFormatValid(null)).isFalse() + } } From f2863608b5bb636f70cc4e3ec235d46fa59fe498 Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:50:15 +0000 Subject: [PATCH 6/7] [MS-1346] Identification response now contains scanned external credential data (#1591) * [MS-1346] Identificaiton response now contains scanned external credential data * [MS-1346] Renaming variables to be more consistent * [MS-1346] Fixing tests * [MS-1346] Fixing tests, removing unnecessary variables * [MS-1346] Fixing tests * [MS-1346] Fixing tests * [MS-1346] Adding test coverage for credential response in Identification response (cherry picked from commit b12990ed4c14a1d9da3706b848491717096a2b64) --- .../feature/clientapi/ClientApiViewModel.kt | 1 + .../response/LibSimprintsResponseMapper.kt | 2 +- .../clientapi/ClientApiViewModelTest.kt | 1 + .../response/CommCareResponseMapperTest.kt | 1 + .../LibSimprintsResponseMapperTest.kt | 77 +++++++++++++++++++ .../mappers/response/OdkResponseMapperTest.kt | 2 + .../MapRefusalOrErrorResultUseCase.kt | 9 ++- .../response/CreateIdentifyResponseUseCase.kt | 17 ++++ .../usecases/AddCallbackEventUseCaseTest.kt | 1 + .../UpdateDailyActivityUseCaseTest.kt | 2 +- .../CreateIdentifyResponseUseCaseTest.kt | 4 + .../orchestration/data/ActionResponse.kt | 1 + .../data/responses/AppIdentifyResponse.kt | 1 + 13 files changed, 116 insertions(+), 3 deletions(-) diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt index ffd42e3912..d4bca49638 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/ClientApiViewModel.kt @@ -147,6 +147,7 @@ class ClientApiViewModel @Inject internal constructor( sessionId = currentSessionId, identifications = identifyResponse.identifications, isMultiFactorIdEnabled = identifyResponse.isMultiFactorIdEnabled, + scannedCredential = identifyResponse.scannedCredential, ), ), ) diff --git a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt index 304ed19497..8a562534b0 100644 --- a/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt +++ b/feature/client-api/src/main/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapper.kt @@ -73,7 +73,7 @@ internal class LibSimprintsResponseMapper @Inject constructor( .toJson(), ) } - } + }.appendExternalCredential(response.scannedCredential.takeIf { response.isMultiFactorIdEnabled }) is ActionResponse.ConfirmActionResponse -> { bundleOf( diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt index 45596dcfb6..ecee946617 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/ClientApiViewModelTest.kt @@ -190,6 +190,7 @@ internal class ClientApiViewModelTest { mockk { every { identifications } returns emptyList() every { isMultiFactorIdEnabled } returns false + every { scannedCredential } returns null }, ) diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt index f9abf3fb49..53f615d29c 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/CommCareResponseMapperTest.kt @@ -61,6 +61,7 @@ class CommCareResponseMapperTest { ), ), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt index 5d3140035f..4aa18e2708 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/LibSimprintsResponseMapperTest.kt @@ -1,5 +1,6 @@ package com.simprints.feature.clientapi.mappers.response +import android.os.Bundle import androidx.test.ext.junit.runners.* import com.google.common.truth.Truth.* import com.simprints.core.domain.externalcredential.ExternalCredentialType @@ -16,6 +17,7 @@ import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapp import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL_TYPE import com.simprints.feature.clientapi.mappers.response.LibSimprintsResponseMapper.Companion.SCANNED_CREDENTIAL_VALUE import com.simprints.infra.orchestration.data.ActionResponse +import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.infra.orchestration.data.responses.AppMatchResult import com.simprints.libsimprints.Constants import com.simprints.libsimprints.contracts.VersionsList @@ -93,6 +95,7 @@ class LibSimprintsResponseMapperTest { ), ), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) @@ -121,6 +124,7 @@ class LibSimprintsResponseMapperTest { ), ), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) @@ -469,6 +473,7 @@ class LibSimprintsResponseMapperTest { sessionId = "sessionId", identifications = listOf(identification1, identification2), isMultiFactorIdEnabled = true, + scannedCredential = null, ), ) @@ -496,6 +501,7 @@ class LibSimprintsResponseMapperTest { ), ), isMultiFactorIdEnabled = true, + scannedCredential = null, ), ) @@ -523,6 +529,7 @@ class LibSimprintsResponseMapperTest { ), ), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) @@ -531,6 +538,76 @@ class LibSimprintsResponseMapperTest { ) } + @Test + fun `when MFID is enabled, identify response contains scanned credential`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val expectedJson = "{\"$SCANNED_CREDENTIAL_VALUE\":\"$expectedValue\",\"$SCANNED_CREDENTIAL_TYPE\":\"$expectedType\"}" + val scannedCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + } + + val extras = mapper( + createIdentifyActionResponse( + isMultiFactorIdEnabled = true, + scannedCredential = scannedCredential, + ), + ) + + assertCommonMfidIdentifyFields(extras) + assertThat(extras.getString(SCANNED_CREDENTIAL)).isEqualTo(expectedJson) + } + + @Test + fun `when MFID is disabled, identify response does not contain credential`() { + val expectedValue = "expectedValue".asTokenizableRaw() + val expectedType = ExternalCredentialType.NHISCard + val scannedCredential = mockk { + every { value } returns expectedValue + every { type } returns expectedType + } + + val extras = mapper( + createIdentifyActionResponse( + isMultiFactorIdEnabled = false, + scannedCredential = scannedCredential, + ), + ) + + assertCommonMfidIdentifyFields(extras) + assertThat(extras.keySet()).doesNotContain(SCANNED_CREDENTIAL) + } + + // Helper functions + private fun createIdentifyActionResponse( + sessionId: String = "sessionId", + isMultiFactorIdEnabled: Boolean, + scannedCredential: AppExternalCredential? = null, + identifications: List = listOf( + AppMatchResult( + guid = "guid-1", + confidenceScore = 100, + matchConfidence = AppMatchConfidence.MEDIUM, + isLinkedToScannedCredential = true, + isCredentialVerified = true, + ), + ), + ) = ActionResponse.IdentifyActionResponse( + actionIdentifier = IdentifyRequestActionFactory.getIdentifier(), + sessionId = sessionId, + identifications = identifications, + isMultiFactorIdEnabled = isMultiFactorIdEnabled, + scannedCredential = scannedCredential, + ) + + private fun assertCommonMfidIdentifyFields(extras: Bundle) { + assertThat(extras.getString(Constants.SIMPRINTS_SESSION_ID)).isEqualTo("sessionId") + assertThat(extras.getString(Constants.SIMPRINTS_DEVICE_ID)).isEqualTo("deviceId") + assertThat(extras.getString(Constants.SIMPRINTS_APP_VERSION_NAME)).isEqualTo("appVersionName") + assertThat(extras.getBoolean(Constants.SIMPRINTS_BIOMETRICS_COMPLETE_CHECK)).isTrue() + } + private fun AppMatchResult.toResponseJson(): String { val jsonBuilder = StringBuilder() jsonBuilder.append("{\"guid\":\"$guid\"") diff --git a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt index 6b1349d889..2459de0d2e 100644 --- a/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt +++ b/feature/client-api/src/test/java/com/simprints/feature/clientapi/mappers/response/OdkResponseMapperTest.kt @@ -55,6 +55,7 @@ class OdkResponseMapperTest { ), ), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) @@ -75,6 +76,7 @@ class OdkResponseMapperTest { sessionId = "sessionId", identifications = listOf(), isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt index 619aad1d04..9aa701fcf6 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/MapRefusalOrErrorResultUseCase.kt @@ -59,7 +59,14 @@ internal class MapRefusalOrErrorResultUseCase @Inject constructor( is ValidateSubjectPoolResult -> result .takeUnless { it.isValid } - ?.let { AppIdentifyResponse(emptyList(), eventRepository.getCurrentSessionScope().id, isMultiFactorIdEnabled = false) } + ?.let { + AppIdentifyResponse( + identifications = emptyList(), + sessionId = eventRepository.getCurrentSessionScope().id, + isMultiFactorIdEnabled = false, + scannedCredential = null, + ) + } is SelectSubjectAgeGroupResult -> result diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt index 0d29bebb07..da21b3ba17 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt @@ -2,11 +2,13 @@ package com.simprints.feature.orchestrator.usecases.response import com.simprints.core.domain.response.AppMatchConfidence import com.simprints.feature.externalcredential.ExternalCredentialSearchResult +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.config.store.models.ModalitySdkType import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.getModalitySdkConfig import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.matching.MatchResult +import com.simprints.infra.orchestration.data.responses.AppExternalCredential import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse import com.simprints.infra.orchestration.data.responses.AppMatchResult import com.simprints.infra.orchestration.data.responses.AppResponse @@ -23,11 +25,26 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( val isMultiFactorIdEnabled = projectConfiguration.multifactorId?.allowedExternalCredentials?.isNotEmpty() ?: false val currentSessionId = eventRepository.getCurrentSessionScope().id + val externalCredential = results + .filterIsInstance(ExternalCredentialSearchResult::class.java) + .lastOrNull() + ?.scannedCredential + .toAppExternalCredential() + return AppIdentifyResponse( sessionId = currentSessionId, isMultiFactorIdEnabled = isMultiFactorIdEnabled, // Return the results with the highest confidence score identifications = getResults(results, projectConfiguration), + scannedCredential = externalCredential, + ) + } + + private fun ScannedCredential?.toAppExternalCredential(): AppExternalCredential? = this?.let { scannedCredential -> + AppExternalCredential( + id = scannedCredential.credentialScanId, + value = scannedCredential.scannedValue, + type = scannedCredential.credentialType, ) } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/AddCallbackEventUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/AddCallbackEventUseCaseTest.kt index 0c44669184..06e55919e9 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/AddCallbackEventUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/AddCallbackEventUseCaseTest.kt @@ -71,6 +71,7 @@ class AddCallbackEventUseCaseTest { listOf(AppMatchResult("guid", 0, AppMatchConfidence.HIGH)), "sessionId", isMultiFactorIdEnabled = false, + scannedCredential = null, ), ) diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/UpdateDailyActivityUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/UpdateDailyActivityUseCaseTest.kt index 5d83449464..916f9d8a36 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/UpdateDailyActivityUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/UpdateDailyActivityUseCaseTest.kt @@ -59,7 +59,7 @@ class UpdateDailyActivityUseCaseTest { @Test fun `Update daily activity on identify response`() = runTest { - useCase(AppIdentifyResponse(emptyList(), "guid", isMultiFactorIdEnabled = false)) + useCase(AppIdentifyResponse(emptyList(), "guid", isMultiFactorIdEnabled = false, scannedCredential = null)) coVerify { recentUserActivityManager.updateRecentUserActivity(any()) } } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt index 4c16737fea..3c4ceb46a3 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt @@ -297,6 +297,7 @@ class CreateIdentifyResponseUseCaseTest { results = listOf( mockk { every { matchResults } returns fingerprintMatches + faceMatches + every { scannedCredential } returns null }, ), ) @@ -333,6 +334,7 @@ class CreateIdentifyResponseUseCaseTest { results = listOf( mockk { every { matchResults } returns credentialFaceMatches + every { scannedCredential } returns null }, MatchResult( listOf(ComparisonResult(subjectId = sharedGuid, comparisonScore = faceConfidence)), @@ -374,6 +376,7 @@ class CreateIdentifyResponseUseCaseTest { results = listOf( mockk { every { matchResults } returns credentialFingerprintMatches + every { scannedCredential } returns null }, MatchResult( listOf(ComparisonResult(subjectId = sharedGuid, comparisonScore = fingerprintConfidence)), @@ -444,6 +447,7 @@ class CreateIdentifyResponseUseCaseTest { results = listOf( mockk { every { matchResults } returns credentialFaceMatches + every { scannedCredential } returns null }, faceMatchResults, ), diff --git a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/ActionResponse.kt b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/ActionResponse.kt index cbb24033ad..59f746c8ff 100644 --- a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/ActionResponse.kt +++ b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/ActionResponse.kt @@ -25,6 +25,7 @@ sealed class ActionResponse( override val sessionId: String, val identifications: List, val isMultiFactorIdEnabled: Boolean, + val scannedCredential: AppExternalCredential?, ) : ActionResponse(actionIdentifier, sessionId) @ExcludedFromGeneratedTestCoverageReports("Data struct") diff --git a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppIdentifyResponse.kt b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppIdentifyResponse.kt index 6b22f9f81a..21019f3402 100644 --- a/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppIdentifyResponse.kt +++ b/infra/orchestrator-data/src/main/java/com/simprints/infra/orchestration/data/responses/AppIdentifyResponse.kt @@ -11,4 +11,5 @@ data class AppIdentifyResponse( val identifications: List, val sessionId: String, val isMultiFactorIdEnabled: Boolean, + val scannedCredential: AppExternalCredential?, ) : AppResponse() From 05bb4e6626d4f6b97b27fcb0bc73d459bd2e162b Mon Sep 17 00:00:00 2001 From: alexandr-simprints <129499142+alexandr-simprints@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:05:32 +0000 Subject: [PATCH 7/7] [MS-1352] MFID: Accessing a correct scanned credential field to build an Identification response (#1594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [MS-1352] Accessing a correct scanned credential field to build an Identification response * [MS-1352] Adding test coverageˆ (cherry picked from commit a5c531b878ea9b7cca7692ad30180a9b6b064ad3) --- .../response/AppResponseBuilderUseCase.kt | 6 +- .../response/CreateIdentifyResponseUseCase.kt | 28 +++++++-- .../response/AppResponseBuilderUseCaseTest.kt | 6 +- .../CreateIdentifyResponseUseCaseTest.kt | 62 ++++++++++++++++++- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCase.kt index 69f2839a59..a181a66195 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCase.kt @@ -29,13 +29,13 @@ internal class AppResponseBuilderUseCase @Inject constructor( request = request, results = results, project = project, - enrolmentSubjectId = enrolmentSubjectId + enrolmentSubjectId = enrolmentSubjectId, ) } else { - handleIdentify(projectConfiguration, results) + handleIdentify(projectConfiguration, project, results) } - is ActionRequest.IdentifyActionRequest -> handleIdentify(projectConfiguration, results) + is ActionRequest.IdentifyActionRequest -> handleIdentify(projectConfiguration, project, results) is ActionRequest.VerifyActionRequest -> handleVerify(projectConfiguration, results) is ActionRequest.ConfirmIdentityActionRequest -> handleConfirmIdentity(results) is ActionRequest.EnrolLastBiometricActionRequest -> handleEnrolLastBiometric(results) diff --git a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt index da21b3ba17..723743f5f6 100644 --- a/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt +++ b/feature/orchestrator/src/main/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCase.kt @@ -1,11 +1,16 @@ package com.simprints.feature.orchestrator.usecases.response import com.simprints.core.domain.response.AppMatchConfidence +import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential import com.simprints.infra.config.store.models.ModalitySdkType +import com.simprints.infra.config.store.models.DecisionPolicy +import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.getModalitySdkConfig +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.matching.MatchResult import com.simprints.infra.orchestration.data.responses.AppExternalCredential @@ -17,9 +22,11 @@ import javax.inject.Inject internal class CreateIdentifyResponseUseCase @Inject constructor( private val eventRepository: SessionEventRepository, + private val tokenizationProcessor: TokenizationProcessor, ) { suspend operator fun invoke( projectConfiguration: ProjectConfiguration, + project: Project?, results: List, ): AppResponse { val isMultiFactorIdEnabled = projectConfiguration.multifactorId?.allowedExternalCredentials?.isNotEmpty() ?: false @@ -29,7 +36,7 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( .filterIsInstance(ExternalCredentialSearchResult::class.java) .lastOrNull() ?.scannedCredential - .toAppExternalCredential() + ?.toAppExternalCredential(tokenizationProcessor, project) return AppIdentifyResponse( sessionId = currentSessionId, @@ -40,11 +47,20 @@ internal class CreateIdentifyResponseUseCase @Inject constructor( ) } - private fun ScannedCredential?.toAppExternalCredential(): AppExternalCredential? = this?.let { scannedCredential -> - AppExternalCredential( - id = scannedCredential.credentialScanId, - value = scannedCredential.scannedValue, - type = scannedCredential.credentialType, + private fun ScannedCredential.toAppExternalCredential( + tokenizationProcessor: TokenizationProcessor, + project: Project?, + ): AppExternalCredential? { + if (project == null) return null + val decryptedValue = tokenizationProcessor.decrypt( + encrypted = credential, + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as? TokenizableString.Raw ?: return null + return AppExternalCredential( + id = credentialScanId, + value = decryptedValue, + type = credentialType, ) } diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCaseTest.kt index 1ff982ba2e..7a09848279 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/AppResponseBuilderUseCaseTest.kt @@ -40,7 +40,7 @@ internal class AppResponseBuilderUseCaseTest { MockKAnnotations.init(this, relaxUnitFun = true) coEvery { handleEnrolment.invoke(any(), any(), any(), any()) } returns mockk() - coEvery { handleIdentify.invoke(any(), any()) } returns mockk() + coEvery { handleIdentify.invoke(any(), any(), any()) } returns mockk() every { handleVerify.invoke(any(), any()) } returns mockk() every { handleConfirmIdentity.invoke(any()) } returns mockk() every { handleEnrolLastBiometric.invoke(any()) } returns mockk() @@ -67,13 +67,13 @@ internal class AppResponseBuilderUseCaseTest { fun `Handles as identification for enrolment action with existing item`() = runTest { every { isNewEnrolment(any(), any()) } returns false useCase(mockk(), mockk(), mockk(), mockk(), enrolmentSubjectId) - coVerify { handleIdentify.invoke(any(), any()) } + coVerify { handleIdentify.invoke(any(), any(), any()) } } @Test fun `Handles as identification for identification action`() = runTest { useCase(mockk(), mockk(), mockk(), mockk(), enrolmentSubjectId) - coVerify { handleIdentify.invoke(any(), any()) } + coVerify { handleIdentify.invoke(any(), any(), any()) } } @Test diff --git a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt index 3c4ceb46a3..ed7dcf2465 100644 --- a/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt +++ b/feature/orchestrator/src/test/java/com/simprints/feature/orchestrator/usecases/response/CreateIdentifyResponseUseCaseTest.kt @@ -1,11 +1,16 @@ package com.simprints.feature.orchestrator.usecases.response import com.google.common.truth.Truth.* +import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.comparison.ComparisonResult +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.CredentialMatch import com.simprints.infra.config.store.models.DecisionPolicy import com.simprints.infra.config.store.models.ModalitySdkType +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.events.session.SessionEventRepository import com.simprints.infra.matching.MatchResult import com.simprints.infra.orchestration.data.responses.AppIdentifyResponse @@ -20,6 +25,12 @@ class CreateIdentifyResponseUseCaseTest { @MockK lateinit var eventRepository: SessionEventRepository + @MockK + lateinit var tokenizationProcessor: TokenizationProcessor + + @MockK + lateinit var project: Project + private lateinit var useCase: CreateIdentifyResponseUseCase @Before @@ -28,7 +39,7 @@ class CreateIdentifyResponseUseCaseTest { coEvery { eventRepository.getCurrentSessionScope().id } returns "sessionId" - useCase = CreateIdentifyResponseUseCase(eventRepository) + useCase = CreateIdentifyResponseUseCase(eventRepository, tokenizationProcessor) } @Test @@ -41,6 +52,7 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf(createFaceMatchResult(10f, 20f, 30f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isEmpty() @@ -56,6 +68,7 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf(createFaceMatchResult(10f, 20f, 30f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -72,6 +85,7 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf(createFaceMatchResult(20f, 25f, 30f, 40f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -88,6 +102,7 @@ class CreateIdentifyResponseUseCaseTest { every { fingerprint?.secugenSimMatcher?.decisionPolicy } returns null }, results = listOf(createFaceMatchResult(15f, 30f, 100f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -108,6 +123,7 @@ class CreateIdentifyResponseUseCaseTest { ) }, results = listOf(createFingerprintMatchResult(10f, 20f, 30f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -128,6 +144,7 @@ class CreateIdentifyResponseUseCaseTest { ) }, results = listOf(createFingerprintMatchResult(20f, 25f, 30f, 40f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -148,6 +165,7 @@ class CreateIdentifyResponseUseCaseTest { ) }, results = listOf(createFingerprintMatchResult(15f, 30f, 100f)), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -171,6 +189,7 @@ class CreateIdentifyResponseUseCaseTest { createFaceMatchResult(15f, 30f, 100f), createFingerprintMatchResult(15f, 30f, 105f), ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -194,6 +213,7 @@ class CreateIdentifyResponseUseCaseTest { createFaceMatchResult(15f, 30f, 105f), createFingerprintMatchResult(15f, 30f, 100f), ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -244,8 +264,10 @@ class CreateIdentifyResponseUseCaseTest { results = listOf( mockk { every { matchResults } returns faceMatches + fingerprintMatches + every { scannedCredential } returns null }, ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -300,6 +322,7 @@ class CreateIdentifyResponseUseCaseTest { every { scannedCredential } returns null }, ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).isNotEmpty() @@ -341,6 +364,7 @@ class CreateIdentifyResponseUseCaseTest { ModalitySdkType.RANK_ONE, ), ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).hasSize(1) @@ -383,6 +407,7 @@ class CreateIdentifyResponseUseCaseTest { ModalitySdkType.SECUGEN_SIM_MATCHER, ), ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).hasSize(1) @@ -451,6 +476,7 @@ class CreateIdentifyResponseUseCaseTest { }, faceMatchResults, ), + project = project, ) assertThat((result as AppIdentifyResponse).identifications).hasSize(maxNbOfReturnedCandidates) @@ -468,6 +494,40 @@ class CreateIdentifyResponseUseCaseTest { ) } + @Test + fun `Returns scanned credential when decryption succeeds`() = runTest { + val id = "id" + val type = ExternalCredentialType.NHISCard + val expectedDecrypted = "expectedDecrypted".asTokenizableRaw() + + every { tokenizationProcessor.decrypt(any(), any(), any()) } returns expectedDecrypted + + val result = useCase( + mockk { + every { multifactorId?.allowedExternalCredentials } returns null + every { identification.maxNbOfReturnedCandidates } returns 2 + every { face?.getSdkConfiguration(any())?.decisionPolicy } returns null + every { fingerprint?.getSdkConfiguration(any())?.decisionPolicy } returns null + }, + results = listOf( + mockk { + every { matchResults } returns emptyList() + every { scannedCredential } returns mockk { + every { credentialScanId } returns id + every { credentialType } returns type + every { credential } returns mockk() + } + }, + ), + project = project, + ) + + assertThat((result as AppIdentifyResponse).scannedCredential).isNotNull() + assertThat(result.scannedCredential?.id).isEqualTo(id) + assertThat(result.scannedCredential?.type).isEqualTo(type) + assertThat(result.scannedCredential?.value).isEqualTo(expectedDecrypted) + } + private fun createFaceMatchResult(vararg confidences: Float): Serializable = MatchResult( confidences.mapIndexed { i, confidence -> ComparisonResult(subjectId = "$i", comparisonScore = confidence) }, ModalitySdkType.RANK_ONE,