Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class ClientApiViewModel @Inject internal constructor(
sessionId = currentSessionId,
identifications = identifyResponse.identifications,
isMultiFactorIdEnabled = identifyResponse.isMultiFactorIdEnabled,
scannedCredential = identifyResponse.scannedCredential,
),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ internal class LibSimprintsResponseMapper @Inject constructor(
.toJson(),
)
}
}
}.appendExternalCredential(response.scannedCredential.takeIf { response.isMultiFactorIdEnabled })

is ActionResponse.ConfirmActionResponse -> {
bundleOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ internal class ClientApiViewModelTest {
mockk {
every { identifications } returns emptyList()
every { isMultiFactorIdEnabled } returns false
every { scannedCredential } returns null
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class CommCareResponseMapperTest {
),
),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -93,6 +95,7 @@ class LibSimprintsResponseMapperTest {
),
),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand Down Expand Up @@ -121,6 +124,7 @@ class LibSimprintsResponseMapperTest {
),
),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand Down Expand Up @@ -469,6 +473,7 @@ class LibSimprintsResponseMapperTest {
sessionId = "sessionId",
identifications = listOf(identification1, identification2),
isMultiFactorIdEnabled = true,
scannedCredential = null,
),
)

Expand Down Expand Up @@ -496,6 +501,7 @@ class LibSimprintsResponseMapperTest {
),
),
isMultiFactorIdEnabled = true,
scannedCredential = null,
),
)

Expand Down Expand Up @@ -523,6 +529,7 @@ class LibSimprintsResponseMapperTest {
),
),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand All @@ -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<AppExternalCredential> {
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<AppExternalCredential> {
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<AppMatchResult> = 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\"")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class OdkResponseMapperTest {
),
),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand All @@ -75,6 +76,7 @@ class OdkResponseMapperTest {
sessionId = "sessionId",
identifications = listOf(),
isMultiFactorIdEnabled = false,
scannedCredential = null,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)

viewModel.init(params)

findNavController().handleResult<ExitFormResult>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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$")
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +33,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
Expand Down Expand Up @@ -58,13 +61,14 @@ 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,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
applySystemBarInsets(view)
initObservers()
}

Expand Down Expand Up @@ -92,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
}
Expand All @@ -102,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(
Expand Down Expand Up @@ -196,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)
Expand Down Expand Up @@ -224,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 {
Expand All @@ -248,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
}
}
Loading