Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2e0b95e
[MS-1164] Initial commit for OCR scanner implementation
alexandr-simprints Sep 26, 2025
88deb7c
[MS-1164] Optimization for OCR scanner. State of fragment-related ele…
alexandr-simprints Sep 28, 2025
1bbde6f
[MS-1164] Adding ExternalCredentialSelectViewModel test coverage
alexandr-simprints Sep 28, 2025
7a77077
[MS-1164] Removing accidental local commit
alexandr-simprints Sep 29, 2025
d9cd8d6
[MS-1164] Removing outdated comments
alexandr-simprints Sep 29, 2025
4d78193
[MS-1164] Moving BoundingBox extensions to the class definition
alexandr-simprints Sep 29, 2025
cfecd89
[MS-1164] Removing extra declarations
alexandr-simprints Sep 29, 2025
9fdfa74
[MS-1164] Adding sanity checks to CalculateLevenshteinDistanceUseCase…
alexandr-simprints Sep 29, 2025
a3ff925
[MS-1164] Moving separateˆ comments into a single one
alexandr-simprints Sep 29, 2025
9acc01a
[MS-1164] Refactoring return type to an assignment
alexandr-simprints Sep 29, 2025
d6310a0
[MS-1164] Removing unnecessary call to viewmodel's field
alexandr-simprints Sep 29, 2025
edce350
[MS-1164] Switching IO dispatcher to BG
alexandr-simprints Sep 29, 2025
867cc00
[MS-1164] Removing unnecessary use case
alexandr-simprints Sep 29, 2025
4b295c4
[MS-1164] Using a regular for-loop to not confusing the "return" stat…
alexandr-simprints Sep 30, 2025
2511e66
[MS-1164] Refactoring GetExternalCredentialBasedOnConfidenceUseCase. …
alexandr-simprints Sep 30, 2025
072d59e
[MS-1164] Adding sanity checks to CalculateLevenshteinDistanceUseCase…
luhmirin-s Sep 30, 2025
6dfd811
Merge branch 'CORE-3404-search-verify' into MS-1164-mf-id-implementat…
alexandr-simprints Sep 30, 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
1 change: 1 addition & 0 deletions feature/external-credential/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dependencies {
implementation(project(":infra:ui-base"))
implementation(project(":feature:exit-form"))
implementation(libs.androidX.cameraX.view)
implementation(libs.mlkit.text.recognition)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.simprints.feature.externalcredential.model

import android.graphics.Rect
import androidx.annotation.Keep
import java.io.Serializable

/**
* A serializable substitute for Android's Rect class, which is not serializable.
* Used for passing rectangular bounds as navigation arguments or in any other scenarios where serialization is required
*/
@Keep
data class BoundingBox(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) : Serializable

internal fun Rect.toBoundingBox(): BoundingBox = BoundingBox(left, top, right, bottom)

internal fun BoundingBox.toRect(): Rect = Rect(left, top, right, bottom)
Original file line number Diff line number Diff line change
@@ -1,9 +1,291 @@
package com.simprints.feature.externalcredential.screens.scanocr

import android.Manifest.permission.CAMERA
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.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
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import javax.inject.Inject
import com.simprints.infra.resources.R as IDR

@AndroidEntryPoint
internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_external_credential_scan_ocr) {
private val args: ExternalCredentialScanOcrFragmentArgs by navArgs()
private val binding by viewBinding(FragmentExternalCredentialScanOcrBinding::bind)
private val mainViewModel: ExternalCredentialViewModel by activityViewModels()
private val viewModel by viewModels<ExternalCredentialScanOcrViewModel> {
object : ViewModelProvider.Factory {
Comment thread
alexandr-simprints marked this conversation as resolved.
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return viewModelFactory.create(args.ocrDocumentType) as T
}
}
}

private val launchPermissionRequest = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
val cameraPermissionStatus = requireActivity().permissionFromResult(CAMERA, granted)
previousPermissionStatus = cameraPermissionStatus
if (cameraPermissionStatus == PermissionStatus.Granted) {
initializeFragment()
} else {
val shouldOpenPhoneSettings = cameraPermissionStatus == PermissionStatus.DeniedNeverAskAgain
renderNoPermission(shouldOpenPhoneSettings)
}
}
private var previousPermissionStatus: PermissionStatus? = null
private lateinit var cameraExecutor: ExecutorService
private lateinit var imageAnalysis: ImageAnalysis

@Inject
lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory

@Inject
lateinit var buildOcrCropConfigUseCase: BuildOcrCropConfigUseCase

@Inject
lateinit var provideCameraListenerUseCase: ProvideCameraListenerUseCase

@Inject
@DispatcherBG
lateinit var bgDispatcher: CoroutineDispatcher

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
applySystemBarInsets(view)
Simber.i("ExternalCredentialScanOcrFragment started", tag = MULTI_FACTOR_ID)
initObservers()
}

override fun onResume() {
super.onResume()
val currentPermission = requireActivity().getCurrentPermissionStatus(CAMERA)
when (currentPermission) {
PermissionStatus.Granted -> initializeFragment()
PermissionStatus.Denied -> {
// Permission dialog was already displayed, and user denied permissions. Showing rationale so to avoid constantly-appearing
// system dialog.
if (previousPermissionStatus == currentPermission) {
renderNoPermission(shouldOpenPhoneSettings = false)
} else {
launchPermissionRequest.launch(CAMERA)
}
}

PermissionStatus.DeniedNeverAskAgain -> {
// Requesting system dialog just in case. Some devices faulty report 'DeniedNeverAskAgain' status when it is actually 'Denied'
launchPermissionRequest.launch(CAMERA)
renderNoPermission(shouldOpenPhoneSettings = true)
}
}

}

override fun onDestroy() {
stopOcr()
stopCamera()
super.onDestroy()
}

private fun initializeFragment() {
renderInitialState()
initCamera(onComplete = {
if (viewModel.isOcrActive) {
startOcr()
}
})

}

private fun initObservers() {
viewModel.stateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
is ScanOcrState.ScanningInProgress -> {
renderProgress(state)
if (state.successfulCaptures >= state.scansRequired) {
stopOcr()
viewModel.processOcrResultsAndFinish()
}
}

ScanOcrState.NotScanning -> renderInitialState()
}
}

viewModel.finishOcrEvent.observe(
viewLifecycleOwner,
LiveDataEventWithContentObserver { detectedBlock ->
finish(detectedBlock)
}
)
}

private fun initCamera(onComplete: () -> Unit) {
if (::cameraExecutor.isInitialized) {
return
}

cameraExecutor = Executors.newSingleThreadExecutor()
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
val cameraListener = provideCameraListenerUseCase(
cameraProviderFuture = cameraProviderFuture,
surfaceProvider = binding.preview.surfaceProvider,
viewLifecycleOwner = viewLifecycleOwner,
onImageAnalysisReady = {
imageAnalysis = it
onComplete()
}
)
cameraProviderFuture.addListener(cameraListener, ContextCompat.getMainExecutor(requireContext()))
}

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
}

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)
documentScannerArea.isVisible = true
progressCard.isVisible = false
buttonScan.isVisible = true
buttonScan.setOnClickListener {
viewModel.ocrStarted()
startOcr()
}
}

private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) {
with(binding) {
instructionsText.isVisible = false
progressCard.isVisible = false
documentScannerArea.isInvisible = true
buttonScan.isVisible = false
val documentTypeText = viewModel.getDocumentTypeRes().run(::getString)
val bodyText = getString(IDR.string.mfid_scan_camera_permission_body, documentTypeText)
if (shouldOpenPhoneSettings) {
permissionRequestView.init(
title = IDR.string.face_capture_permission_denied,
body = bodyText,
buttonText = IDR.string.fingerprint_connect_phone_settings_button,
onClickListener = {
requireActivity().startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
"package:${requireActivity().packageName}".toUri(),
),
)
}
)
} else {
permissionRequestView.init(
title = IDR.string.face_capture_permission_denied,
body = bodyText,
buttonText = IDR.string.face_capture_permission_action,
onClickListener = {
launchPermissionRequest.launch(CAMERA)
}
)
}
permissionRequestView.isVisible = true
}
}

private fun startOcr() {
imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
if (viewModel.isRunningOcrOnFrame) {
imageProxy.close()
return@setAnalyzer
}
viewModel.ocrOnFrameStarted()
lifecycleScope.launch(bgDispatcher) {
try {
val (bitmap, imageInfo) = imageProxy.toBitmap() to imageProxy.imageInfo
val cropConfig: OcrCropConfig = buildOcrCropConfigUseCase(
rotationDegrees = imageInfo.rotationDegrees,
cameraPreview = binding.preview,
documentScannerArea = binding.documentScannerArea
)
viewModel.runOcrOnFrame(frame = bitmap, cropConfig)
} finally {
imageProxy.close()
}
}
}
}

private fun stopCamera() {
if (::cameraExecutor.isInitialized) {
cameraExecutor.shutdown()
}
}

private fun stopOcr() {
if (::imageAnalysis.isInitialized) {
imageAnalysis.clearAnalyzer()
}
}

private fun finish(detectedBlock: DetectedOcrBlock) {
val credentialType = when (detectedBlock.documentType) {
OcrDocumentType.NhisCard -> ExternalCredentialType.NHISCard
OcrDocumentType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard
}
val args = ScannedCredential(
credential = detectedBlock.readoutValue,
credentialType = credentialType,
previewImagePath = detectedBlock.imagePath,
imageBoundingBox = detectedBlock.blockBoundingBox
)
findNavController().navigateSafely(
this@ExternalCredentialScanOcrFragment,
ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(args)
)
}
}
Loading