-
Notifications
You must be signed in to change notification settings - Fork 2
[MS-1164] Multi-Factor ID. OCR Scanner screen #1379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
alexandr-simprints
merged 17 commits into
CORE-3404-search-verify
from
MS-1164-mf-id-implementation-ocr-scanner
Sep 30, 2025
Merged
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 88deb7c
[MS-1164] Optimization for OCR scanner. State of fragment-related ele…
alexandr-simprints 1bbde6f
[MS-1164] Adding ExternalCredentialSelectViewModel test coverage
alexandr-simprints 7a77077
[MS-1164] Removing accidental local commit
alexandr-simprints d9cd8d6
[MS-1164] Removing outdated comments
alexandr-simprints 4d78193
[MS-1164] Moving BoundingBox extensions to the class definition
alexandr-simprints cfecd89
[MS-1164] Removing extra declarations
alexandr-simprints 9fdfa74
[MS-1164] Adding sanity checks to CalculateLevenshteinDistanceUseCase…
alexandr-simprints a3ff925
[MS-1164] Moving separateˆ comments into a single one
alexandr-simprints 9acc01a
[MS-1164] Refactoring return type to an assignment
alexandr-simprints d6310a0
[MS-1164] Removing unnecessary call to viewmodel's field
alexandr-simprints edce350
[MS-1164] Switching IO dispatcher to BG
alexandr-simprints 867cc00
[MS-1164] Removing unnecessary use case
alexandr-simprints 4b295c4
[MS-1164] Using a regular for-loop to not confusing the "return" stat…
alexandr-simprints 2511e66
[MS-1164] Refactoring GetExternalCredentialBasedOnConfidenceUseCase. …
alexandr-simprints 072d59e
[MS-1164] Adding sanity checks to CalculateLevenshteinDistanceUseCase…
luhmirin-s 6dfd811
Merge branch 'CORE-3404-search-verify' into MS-1164-mf-id-implementat…
alexandr-simprints File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
...al-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
282 changes: 282 additions & 0 deletions
282
...simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrFragment.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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) | ||
| ) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.