Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
38cbf52
[MS-1167] Implementing zoom-in onto scanend external credential area
alexandr-simprints Sep 29, 2025
9e663be
[MS-1167] Rendering views based on state on External Credential searc…
alexandr-simprints Sep 29, 2025
8d4524c
Merge branch 'CORE-3404-search-verify' into MS-1167-mf-id-implementat…
alexandr-simprints Sep 30, 2025
a463d2f
[MS-1190] Updating UI of OCR fragment - surrounding area goes white w…
alexandr-simprints Oct 2, 2025
f4e5272
[MS-1167] Updating UI of OCR fragment - adding animations for completion
alexandr-simprints Oct 2, 2025
998f0c4
[MS-1167] Intermediate implementation of the Credential Search screen…
alexandr-simprints Oct 3, 2025
082b473
[MS-1167] Adding unit tests
alexandr-simprints Oct 3, 2025
74e4b0e
[MS-1167] Zoomed-in image of credential is now created in the Externa…
alexandr-simprints Oct 4, 2025
3067804
[MS-1167] External Credential values are now using explicit Tokenizab…
alexandr-simprints Oct 6, 2025
80b987b
[MS-1167] UI updates: adding a separate landscape view for credential…
alexandr-simprints Oct 6, 2025
04c46d2
[MS-1167] Refactoring ViewExt.kt to use expression body
alexandr-simprints Oct 7, 2025
5ef369f
[MS-1167] Refactoring ViewExt.kt parameters order
alexandr-simprints Oct 7, 2025
aa117d0
[MS-1167] Renaming mappers according to conventions
alexandr-simprints Oct 7, 2025
9937398
[MS-1167] Refactoring to use scale factor instead of percentage. Simp…
alexandr-simprints Oct 7, 2025
4d334d9
[MS-1167] Using compat methods; removing magic constants
alexandr-simprints Oct 7, 2025
30a0308
[MS-1167] Refactoring to use expression body
alexandr-simprints Oct 7, 2025
7033f52
[MS-1167] Refactoring with KtLint
alexandr-simprints Oct 7, 2025
d86bc56
[MS-1167] Using common style instead of duplicating
alexandr-simprints Oct 7, 2025
420d0d0
[MS-1167] Making test in ZoomOntoCredentialUseCaseTest more comprehen…
alexandr-simprints Oct 7, 2025
9d0092d
[MS-1167] Refactoring to use expression bodyˆ
alexandr-simprints Oct 7, 2025
ec212c9
[MS-1167] Applying KtLint
alexandr-simprints Oct 7, 2025
11895f2
[MS-1167] Applying KtLint
alexandr-simprints Oct 7, 2025
a4aa5ef
[MS-1167] Making comments more consistent and avoiding usage of magic…
alexandr-simprints Oct 7, 2025
e6f3d85
[MS-1167] Applying KtLint formatting
alexandr-simprints Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions feature/external-credential/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dependencies {
implementation(project(":infra:config-sync"))
implementation(project(":infra:ui-base"))
implementation(project(":feature:exit-form"))
implementation(project(":infra:enrolment-records:repository"))
implementation(project(":infra:auth-store"))
implementation(project(":infra:matching"))
implementation(libs.androidX.cameraX.view)
implementation(libs.mlkit.text.recognition)
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
package com.simprints.feature.externalcredential

import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.common.FlowType
import com.simprints.feature.externalcredential.model.ExternalCredentialParams
import com.simprints.infra.config.store.models.AgeGroup
import com.simprints.infra.matching.MatchParams

@ExcludedFromGeneratedTestCoverageReports("Navigation class")
object ExternalCredentialContract {
val DESTINATION = R.id.externalCredentialControllerFragment

fun getParams(
subjectId: String?,
flowType: FlowType,
) = ExternalCredentialParams(subjectId = subjectId, flowType = flowType)
ageGroup: AgeGroup?,
probeReferenceId: String? = null,
faceSamples: List<MatchParams.FaceSample> = emptyList(),
fingerprintSamples: List<MatchParams.FingerprintSample> = emptyList(),
) = ExternalCredentialParams(
subjectId = subjectId,
flowType = flowType,
ageGroup = ageGroup,
probeReferenceId = probeReferenceId,
faceSamples = faceSamples,
fingerprintSamples = fingerprintSamples,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.simprints.feature.externalcredential

import androidx.annotation.Keep
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.common.FlowType
import com.simprints.core.domain.step.StepResult
import com.simprints.feature.externalcredential.model.CredentialMatch
import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential

/**
* Results of the external credential 1:L match (where L is '1' or really close to 1).
*
* @param flowType flow type. Either [FlowType.ENROL] or [FlowType.IDENTIFY]
* @param scannedCredential information about the credential that was scanned
* @param matchResults if [scannedCredential] exists in local database, this field contains match results between the biometric probe taken
* during the flow, and probes linked to the [scannedCredential]
*/
@Keep
@ExcludedFromGeneratedTestCoverageReports("Data class")
data class ExternalCredentialSearchResult(
val flowType: FlowType,
val scannedCredential: ScannedCredential,
val matchResults: List<CredentialMatch>,
) : StepResult {
val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.simprints.feature.externalcredential.ext

import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports

@ExcludedFromGeneratedTestCoverageReports("View animation")
fun View.fadeOut(
duration: Long,
scaleX: Boolean,
fragment: Fragment,
) = animate()
.alpha(0f)
.setDuration(duration)
.setInterpolator(DecelerateInterpolator())
.withEndAction {
if (fragment.isAdded) {
this.isVisible = false
}
}.also {
if (scaleX) {
it.scaleX(0f)
}
it.start()
}

@ExcludedFromGeneratedTestCoverageReports("View animation")
fun View.fadeIn(
duration: Long,
fragment: Fragment,
onComplete: (() -> Unit)?,
) = animate()
.alpha(1f)
.setInterpolator(AccelerateInterpolator())
.setDuration(duration)
.withEndAction {
if (fragment.isAdded) {
this.isVisible = true
onComplete?.invoke()
}
}.also { it.start() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.simprints.feature.externalcredential.model

import androidx.annotation.Keep
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.tokenization.TokenizableString
import com.simprints.infra.config.store.models.FaceConfiguration
import com.simprints.infra.config.store.models.FingerprintConfiguration
import com.simprints.infra.matching.MatchResultItem

@Keep
@ExcludedFromGeneratedTestCoverageReports("Data class")
data class CredentialMatch(
val credential: TokenizableString.Tokenized,
val matchResult: MatchResultItem,
val verificationThreshold: Float,
val faceBioSdk: FaceConfiguration.BioSdk?,
val fingerprintBioSdk: FingerprintConfiguration.BioSdk?,
) {
val isVerificationSuccessful = matchResult.confidence >= verificationThreshold
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package com.simprints.feature.externalcredential.model

import androidx.annotation.Keep
import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.common.FlowType
import com.simprints.core.domain.step.StepParams
import com.simprints.infra.config.store.models.AgeGroup
import com.simprints.infra.matching.MatchParams

@Keep
@ExcludedFromGeneratedTestCoverageReports("Data class")
data class ExternalCredentialParams(
val subjectId: String?,
val flowType: FlowType,
val ageGroup: AgeGroup?,
val probeReferenceId: String?,
val faceSamples: List<MatchParams.FaceSample>,
val fingerprintSamples: List<MatchParams.FingerprintSample>,
) : StepParams
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.simprints.core.livedata.LiveDataEventWithContentObserver
import com.simprints.feature.exitform.ExitFormContract
import com.simprints.feature.exitform.ExitFormResult
import com.simprints.feature.externalcredential.GraphExternalCredentialInternalDirections
Expand All @@ -17,7 +18,6 @@ import com.simprints.infra.uibase.navigation.handleResult
import com.simprints.infra.uibase.navigation.navigateSafely
import com.simprints.infra.uibase.navigation.navigationParams
import dagger.hilt.android.AndroidEntryPoint
import kotlin.getValue

@AndroidEntryPoint
internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment_external_credential_controller) {
Expand Down Expand Up @@ -65,6 +65,12 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment
private fun initObservers() {
viewModel.stateLiveData.observe(viewLifecycleOwner) {
}
viewModel.finishEvent.observe(
viewLifecycleOwner,
LiveDataEventWithContentObserver { result ->
findNavController().finishWithResult(this, result)
},
)
}

private fun initListeners() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.simprints.core.domain.externalcredential.ExternalCredentialType
import com.simprints.core.livedata.LiveDataEventWithContent
import com.simprints.core.livedata.send
import com.simprints.feature.externalcredential.ExternalCredentialSearchResult
import com.simprints.feature.externalcredential.model.ExternalCredentialParams
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
Expand All @@ -12,6 +15,11 @@ import com.simprints.infra.resources.R as IDR
@HiltViewModel
internal class ExternalCredentialViewModel @Inject internal constructor() : ViewModel() {
private var isInitialized = false
lateinit var params: ExternalCredentialParams
private set
val finishEvent: LiveData<LiveDataEventWithContent<ExternalCredentialSearchResult>>
get() = _finishEvent
private val _finishEvent = MutableLiveData<LiveDataEventWithContent<ExternalCredentialSearchResult>>()
private var state: ExternalCredentialState = ExternalCredentialState.EMPTY
set(value) {
field = value
Expand All @@ -35,6 +43,7 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View
fun init(params: ExternalCredentialParams) {
if (!isInitialized) {
isInitialized = true
this.params = params
updateState { ExternalCredentialState.EMPTY.copy(subjectId = params.subjectId, flowType = params.flowType) }
}
}
Expand All @@ -45,4 +54,14 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View
ExternalCredentialType.QRCode -> IDR.string.mfid_type_qr_code
null -> IDR.string.mfid_type_any_document
}

fun mapTypeToCredentialFieldResource(type: ExternalCredentialType) = when (type) {
ExternalCredentialType.NHISCard -> IDR.string.mfid_nhis_card_credential_field
ExternalCredentialType.GhanaIdCard -> IDR.string.mfid_ghana_id_credential_field
ExternalCredentialType.QRCode -> IDR.string.mfid_qr_credential_field
}

fun finish(result: ExternalCredentialSearchResult) {
_finishEvent.send(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
Expand All @@ -21,17 +22,16 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.simprints.core.DispatcherBG
import com.simprints.core.domain.externalcredential.ExternalCredentialType
import com.simprints.core.domain.permission.PermissionStatus
import com.simprints.core.livedata.LiveDataEventWithContentObserver
import com.simprints.core.tools.extentions.getCurrentPermissionStatus
import com.simprints.core.tools.extentions.permissionFromResult
import com.simprints.feature.externalcredential.R
import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanOcrBinding
import com.simprints.feature.externalcredential.ext.fadeIn
import com.simprints.feature.externalcredential.ext.fadeOut
import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel
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
import com.simprints.feature.externalcredential.screens.scanocr.usecase.BuildOcrCropConfigUseCase
import com.simprints.feature.externalcredential.screens.scanocr.usecase.ProvideCameraListenerUseCase
import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential
Expand Down Expand Up @@ -77,6 +77,10 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
private var previousPermissionStatus: PermissionStatus? = null
private lateinit var cameraExecutor: ExecutorService
private lateinit var imageAnalysis: ImageAnalysis
private var progressAnimator: ViewPropertyAnimator? = null
private var checkAnimator: ViewPropertyAnimator? = null
private var isAnimatingCompletion: Boolean = false
private var pendingFinishAction: (() -> Unit)? = null

@Inject
lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory
Expand Down Expand Up @@ -127,9 +131,17 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
override fun onDestroy() {
stopOcr()
stopCamera()
clearAnimations()
super.onDestroy()
}

private fun clearAnimations() {
pendingFinishAction = null
isAnimatingCompletion = false
checkAnimator?.cancel()
progressAnimator?.cancel()
}

private fun initializeFragment() {
renderInitialState()
initCamera(onComplete = {
Expand All @@ -151,13 +163,14 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
}

ScanOcrState.NotScanning -> renderInitialState()
ScanOcrState.Complete -> animateCompletionState()
}
}

viewModel.finishOcrEvent.observe(
viewLifecycleOwner,
LiveDataEventWithContentObserver { detectedBlock ->
finish(detectedBlock)
LiveDataEventWithContentObserver { scannedCredential ->
scheduleFinish(scannedCredential)
},
)
}
Expand All @@ -184,28 +197,48 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
private fun renderProgress(state: ScanOcrState.ScanningInProgress) = with(binding) {
val progressPercentage = (state.successfulCaptures * 100 / state.scansRequired).coerceAtMost(100)
buttonScan.isVisible = false
progressCard.isVisible = true
progressBar.progress = progressPercentage
progressContainer.isVisible = true
progressBar.isVisible = true
iconScanComplete.alpha = 0f
progressBar.setProgressCompat(progressPercentage, true)
instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_black))
viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_white))
viewfinderMask.alpha = VIEW_FINDER_ALPHA_SCAN_ACTIVE
}

private fun renderInitialState() = with(binding) {
val documentTypeText = viewModel.getDocumentTypeRes().run(::getString)
permissionRequestView.isVisible = false
instructionsText.isVisible = true
instructionsText.text = getString(IDR.string.mfid_scan_instructions, documentTypeText)
instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white))
documentScannerArea.isVisible = true
progressCard.isVisible = false
progressContainer.isVisible = false
buttonScan.isVisible = true
buttonScan.setOnClickListener {
viewModel.ocrStarted()
startOcr()
}
viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_black))
viewfinderMask.alpha = VIEW_FINDER_ALPHA_INITIAL
}

private fun animateCompletionState() = with(binding) {
isAnimatingCompletion = true
progressBar.fadeOut(FINISH_ANIMATION_DURATION, scaleX = true, fragment = this@ExternalCredentialScanOcrFragment)
scanInstructions.fadeOut(FINISH_ANIMATION_DURATION, scaleX = false, fragment = this@ExternalCredentialScanOcrFragment)
iconScanComplete.fadeIn(FINISH_ANIMATION_DURATION, fragment = this@ExternalCredentialScanOcrFragment, onComplete = {
isAnimatingCompletion = false
// Execute any pending action after the animation. Currently used is for next fragment navigation
pendingFinishAction?.invoke()
pendingFinishAction = null
})
}

private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) {
with(binding) {
instructionsText.isVisible = false
progressCard.isVisible = false
progressContainer.isVisible = false
documentScannerArea.isInvisible = true
buttonScan.isVisible = false
val documentTypeText = viewModel.getDocumentTypeRes().run(::getString)
Expand Down Expand Up @@ -273,20 +306,31 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex
}
}

private fun finish(detectedBlock: DetectedOcrBlock) {
val credentialType = when (detectedBlock.documentType) {
OcrDocumentType.NhisCard -> ExternalCredentialType.NHISCard
OcrDocumentType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard
/**
* Waits until all animations are complete before navigating away. Completion animations are in place because the execution of
* [ExternalCredentialScanOcrViewModel.processOcrResultsAndFinish] is not immediate, and it makes the transition to the next fragment
* smoother for user.
*
* The animation state is stored in the [isAnimatingCompletion]. If it is set to true, the navigation action is set to
* [pendingFinishAction] which will be executed once animations are complete. If false, the navigation will proceed immediately.
*/
private fun scheduleFinish(credential: ScannedCredential) {
val navigationAction = {
findNavController().navigateSafely(
this@ExternalCredentialScanOcrFragment,
ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(credential),
)
}
val args = ScannedCredential(
credential = detectedBlock.readoutValue,
credentialType = credentialType,
previewImagePath = detectedBlock.imagePath,
imageBoundingBox = detectedBlock.blockBoundingBox,
)
findNavController().navigateSafely(
this@ExternalCredentialScanOcrFragment,
ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(args),
)
if (isAnimatingCompletion) {
pendingFinishAction = navigationAction
} else {
navigationAction.invoke()
}
}

companion object {
private const val VIEW_FINDER_ALPHA_INITIAL = 0.5f
private const val VIEW_FINDER_ALPHA_SCAN_ACTIVE = 0.9f
private const val FINISH_ANIMATION_DURATION = 300L
}
}
Loading