diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index b3d0f060fa..91c7634400 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -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) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt new file mode 100644 index 0000000000..f8ad0e13d7 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/BoundingBox.kt @@ -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) 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 c055da0ae6..d4b53eb5fc 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 @@ -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 { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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) + ) + } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt new file mode 100644 index 0000000000..9c470edcf0 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ExternalCredentialScanOcrViewModel.kt @@ -0,0 +1,112 @@ +package com.simprints.feature.externalcredential.screens.scanocr + +import android.graphics.Bitmap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.core.DispatcherBG +import com.simprints.core.livedata.LiveDataEventWithContent +import com.simprints.core.livedata.send +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.CropDocumentFromPreviewUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.GetCredentialCoordinatesUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.KeepOnlyBestDetectedBlockUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.NormalizeBitmapToPreviewUseCase +import com.simprints.infra.logging.Simber +import com.simprints.infra.resources.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch + +internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( + @Assisted val ocrDocumentType: OcrDocumentType, + private val normalizeBitmapToPreviewUseCase: NormalizeBitmapToPreviewUseCase, + private val cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase, + private val getCredentialCoordinatesUseCase: GetCredentialCoordinatesUseCase, + private val keepOnlyBestDetectedBlockUseCase: KeepOnlyBestDetectedBlockUseCase, + @DispatcherBG private val bgDispatcher: CoroutineDispatcher +) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(ocrDocumentType: OcrDocumentType): ExternalCredentialScanOcrViewModel + } + + private var detectedBlocks: List = emptyList() + var isRunningOcrOnFrame: Boolean = false + private set + val isOcrActive: Boolean + get() = detectedBlocks.isNotEmpty() + private var state: ScanOcrState = ScanOcrState.EMPTY + set(value) { + field = value + _stateLiveData.postValue(value) + } + private val _stateLiveData = MutableLiveData() + val stateLiveData: LiveData = _stateLiveData + val finishOcrEvent: LiveData> + get() = _finishOcrEvent + private val _finishOcrEvent = MutableLiveData>() + + private fun updateState(state: (ScanOcrState) -> ScanOcrState) { + this.state = state(this.state) + } + + fun getDocumentTypeRes(): Int = when (ocrDocumentType) { + OcrDocumentType.NhisCard -> R.string.mfid_type_nhis_card + OcrDocumentType.GhanaIdCard -> R.string.mfid_type_ghana_id_card + } + + fun ocrStarted() { + updateState { + ScanOcrState.ScanningInProgress( + ocrDocumentType = ocrDocumentType, + successfulCaptures = 0, + scansRequired = SUCCESSFUL_SCANS_REQUIRED + ) + } + } + + fun runOcrOnFrame(frame: Bitmap, cropConfig: OcrCropConfig) { + viewModelScope.launch(bgDispatcher) { + try { + Simber.d("started OCR") + val normalizedBitmap = normalizeBitmapToPreviewUseCase(frame, cropConfig) + val cropped = cropDocumentFromPreviewUseCase(bitmap = normalizedBitmap, cutoutRect = cropConfig.cutoutRect) + val detectedBlock = getCredentialCoordinatesUseCase(bitmap = cropped, documentType = ocrDocumentType) ?: return@launch + Simber.d("Detected OCR") + detectedBlocks += detectedBlock + updateState { + ScanOcrState.ScanningInProgress( + ocrDocumentType = ocrDocumentType, + successfulCaptures = detectedBlocks.size, + scansRequired = SUCCESSFUL_SCANS_REQUIRED + ) + } + } finally { + isRunningOcrOnFrame = false + } + } + } + + fun processOcrResultsAndFinish() { + viewModelScope.launch { + val detectedBlock = keepOnlyBestDetectedBlockUseCase(detectedBlocks, ocrDocumentType) + _finishOcrEvent.send(detectedBlock) + detectedBlocks = emptyList() + updateState { ScanOcrState.NotScanning } + } + } + + fun ocrOnFrameStarted() { + isRunningOcrOnFrame = true + } + + companion object { + private const val SUCCESSFUL_SCANS_REQUIRED = 5 + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt new file mode 100644 index 0000000000..f38275f5c7 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/ScanOcrState.kt @@ -0,0 +1,16 @@ +package com.simprints.feature.externalcredential.screens.scanocr + +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType + + +internal sealed class ScanOcrState { + data object NotScanning : ScanOcrState() + data class ScanningInProgress( + val ocrDocumentType: OcrDocumentType, + val successfulCaptures: Int, + val scansRequired: Int, + ) : ScanOcrState() + companion object { + val EMPTY = ScanOcrState.NotScanning + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt new file mode 100644 index 0000000000..8e9f5845ef --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/DetectedOcrBlock.kt @@ -0,0 +1,25 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import com.google.mlkit.vision.text.Text +import com.simprints.feature.externalcredential.model.BoundingBox + +/** + * Result of the OCR credential detection of image. [Text.TextBlock] contains a [Text.Line] that was contains the detected credential. + * [readoutValue] is a normalized string that was read from the [Text.Line] (no extra space, trimmed). + * + * To save memory, the image is not stored directly in the data class. Rather this data class keeps a file path reference to the image + * in [imagePath]. + * + * @param imagePath path to bitmap that was used for OCR + * @param documentType type of a supported document + * @param blockBoundingBox bounding box of block in which [lineBoundingBox] was detected + * @param lineBoundingBox bounding box of line that contained [Text.Element] objects that were concatenated and normalized to produce a [readoutValue] + * @param readoutValue normalized readout value from all [Text.Element] objects in [lineBoundingBox] + */ +internal data class DetectedOcrBlock( + val imagePath: String, + val documentType: OcrDocumentType, + val blockBoundingBox: BoundingBox, + val lineBoundingBox: BoundingBox, + val readoutValue: String, +) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt new file mode 100644 index 0000000000..71cbe48a62 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrCropConfig.kt @@ -0,0 +1,10 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +import android.graphics.Rect + +internal data class OcrCropConfig( + val rotationDegrees: Int, + val cutoutRect: Rect, + val previewViewWidth: Int, + val previewViewHeight: Int +) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt new file mode 100644 index 0000000000..4759e04ef0 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/model/OcrDocumentType.kt @@ -0,0 +1,5 @@ +package com.simprints.feature.externalcredential.screens.scanocr.model + +enum class OcrDocumentType { + NhisCard, GhanaIdCard +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt new file mode 100644 index 0000000000..f5e79cf022 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/BuildOcrCropConfigUseCase.kt @@ -0,0 +1,20 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.view.View +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig +import javax.inject.Inject + +internal class BuildOcrCropConfigUseCase @Inject constructor( + private val getBoundsRelativeToParentUseCase: GetBoundsRelativeToParentUseCase +) { + + operator fun invoke(rotationDegrees: Int, cameraPreview: View, documentScannerArea: View): OcrCropConfig { + val cutoutRect = getBoundsRelativeToParentUseCase(parent = cameraPreview, child = documentScannerArea) + return OcrCropConfig( + rotationDegrees = rotationDegrees, + cutoutRect = cutoutRect, + previewViewWidth = cameraPreview.width, + previewViewHeight = cameraPreview.height + ) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt new file mode 100644 index 0000000000..4975db03c0 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CalculateLevenshteinDistanceUseCase.kt @@ -0,0 +1,45 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject +import kotlin.math.min + +internal class CalculateLevenshteinDistanceUseCase @Inject constructor() { + + /** + * Calculates the Levenshtein distance between two strings. + * + * The Levenshtein distance is the minimum number of single-character edits (insertions, deletions, or substitutions) required to change + * one string into another. + * + * Examples: + * - "kitten" -> "sitting" = 3 (substitute k->s, e->i, insert g) + * - "ABC" -> "ACD" = 1 (substitute B->C) + * - "hello" -> "hello" = 0 (identical strings) + * + * @param s1 first string + * @param s2 second string + * @return minimum number of edits needed to transform s1 into s2 + */ + operator fun invoke(s1: String, s2: String): Int { + if (s1 == s2) return 0 + if (s1.isEmpty()) return s2.length + if (s2.isEmpty()) return s1.length + val dp = Array(s1.length + 1) { IntArray(s2.length + 1) } + for (i in 0..s1.length) dp[i][0] = i + for (j in 0..s2.length) dp[0][j] = j + for (i in 1..s1.length) { + for (j in 1..s2.length) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + dp[i][j] = min( + min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1 + ), + dp[i - 1][j - 1] + cost + ) + } + } + + return dp[s1.length][s2.length] + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt new file mode 100644 index 0000000000..0790d8062c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/CropDocumentFromPreviewUseCase.kt @@ -0,0 +1,22 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.Rect +import javax.inject.Inject + +internal class CropDocumentFromPreviewUseCase @Inject constructor() { + operator fun invoke( + bitmap: Bitmap, + cutoutRect: Rect + ): Bitmap { + val left = cutoutRect.left.coerceIn(0, bitmap.width) + val top = cutoutRect.top.coerceIn(0, bitmap.height) + val right = cutoutRect.right.coerceIn(left, bitmap.width) + val bottom = cutoutRect.bottom.coerceIn(top, bitmap.height) + + val width = right - left + val height = bottom - top + + return Bitmap.createBitmap(bitmap, left, top, width, height) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt new file mode 100644 index 0000000000..0134d18b9f --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/DeleteScannedImageUseCase.kt @@ -0,0 +1,35 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.core.DispatcherIO +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +internal class DeleteScannedImageUseCase @Inject constructor( + @DispatcherIO private val ioDispatcher: CoroutineDispatcher +) { + /** + * Deletes a file from the given absolute path. + * Only deletes files within the application's cache directory for security. + * @param filePath the absolute path to the file to delete + * @return true if the file was successfully deleted, false otherwise + */ + suspend operator fun invoke(filePath: String) { + withContext(ioDispatcher) { + try { + val file = File(filePath) + if (file.exists() && file.isFile) { + file.delete() + } else { + throw IllegalArgumentException("Cached OCR image [$filePath] doesn't exist") + } + } catch (e: Exception) { + Simber.e("OCR: Unable to delete cached scan file [$filePath]", e, tag = MULTI_FACTOR_ID) + throw(e) + } + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt new file mode 100644 index 0000000000..4f6e372e80 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/FindBestTextBlockForCredentialUseCase.kt @@ -0,0 +1,40 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock +import javax.inject.Inject + +internal class FindBestTextBlockForCredentialUseCase @Inject constructor( + private val calculateLevenshteinDistanceUseCase: CalculateLevenshteinDistanceUseCase +) { + + /** + * Finds the detected OCR block that contains the readout value most similar to the given credential string. + * + * This method first attempts to find an exact match by comparing the readout value of each block with the credential. If no exact + * match is found, it uses Levenshtein distance to find the block with the smallest edit distance to the credential. + * + * @param credential the target credential string to match + * @param detectedBlocks list of detected OCR blocks to search through. Must not be empty + * @return the detected OCR block with the best matching readout value + * @throws IllegalArgumentException if no blocks provided + */ + operator fun invoke(credential: String, detectedBlocks: List): DetectedOcrBlock { + if (detectedBlocks.isEmpty()) { + throw IllegalArgumentException("OCR: cannot find match for credential, provided detected block list is empty") + } + + // Searching from the end of detected blocks to maximize chances of getting the image closest to what the user have seen on the + // camera preview. This allows for natural look when transitioning to the next screen, as the best fitting text block will be as + // close to the last frame the user sees as possible. + for (block in detectedBlocks.asReversed()) { + if (block.readoutValue == credential) { + return block + } + } + + // If no exact match, finding the closest one using Levenshtein distance. Updating its credential value to the given for consistency + return detectedBlocks.minBy { block -> + calculateLevenshteinDistanceUseCase(credential, block.readoutValue) + }.copy(readoutValue = credential) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBoundsRelativeToParentUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBoundsRelativeToParentUseCase.kt new file mode 100644 index 0000000000..7e257cc2c7 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetBoundsRelativeToParentUseCase.kt @@ -0,0 +1,26 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Rect +import android.view.View +import javax.inject.Inject + +internal class GetBoundsRelativeToParentUseCase @Inject constructor() { + + operator fun invoke(parent: View, child: View): Rect { + val childLocation = IntArray(2) + val parentLocation = IntArray(2) + child.getLocationOnScreen(childLocation) + parent.getLocationOnScreen(parentLocation) + + val offsetX = childLocation[0] - parentLocation[0] + val offsetY = childLocation[1] - parentLocation[1] + + return Rect( + offsetX, + offsetY, + offsetX + child.width, + offsetY + child.height + ) + } + +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt new file mode 100644 index 0000000000..433cb913e2 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetCredentialCoordinatesUseCase.kt @@ -0,0 +1,76 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.feature.externalcredential.model.toBoundingBox +import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +@ExcludedFromGeneratedTestCoverageReports("Unable to mock Google ML Kit") +internal class GetCredentialCoordinatesUseCase @Inject constructor( + private val ghanaNhisCardOcrSelectorUseCase: GhanaNhisCardOcrSelectorUseCase, + private val ghanaIdCardOcrSelectorUseCase: GhanaIdCardOcrSelectorUseCase, + private val saveScannedImageUseCase: SaveScannedImageUseCase, +) { + private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + + /** + * OCR uses Google ML kit. It has a following hierarchy: + * - Block. A contiguous set of text lines, such as a paragraph or column, + * - Line. A contiguous set of words on the same axis. There can be multiple Lines in the Block + * - Element. A contiguous set of alphanumeric characters ("word") on the same axis. There can be Elements in one Line + * - Symbol. A single alphanumeric character in an Element. + * + * This method returns a [DetectedOcrBlock] class if the OCR managed to find a line that satisfies the given [documentType] pattern. + * If such Line is found, then it is returned in a [DetectedOcrBlock] alongside its parent block, and a normalized value. + * + * Lines are used instead of Elements because the OCR might mistakenly read an extra white space in a Line, resulting in multiple + * Elements. Since Lines are geometrically in one plane, we just take the concatenation of all underlying child Elements, and analyze + * them it as a single string. + * + * @param bitmap bitmap to run OCR on + * @param documentType type of the document + * + * @return [DetectedOcrBlock] if any Line satisfies the [documentType] pattern, or null if none. + */ + suspend operator fun invoke(bitmap: Bitmap, documentType: OcrDocumentType): DetectedOcrBlock? { + val image = InputImage.fromBitmap(bitmap, 0) + return try { + val result = Tasks.await(recognizer.process(image)) ?: return null + return result.textBlocks.firstNotNullOfOrNull { textBlock -> + textBlock.lines.firstNotNullOfOrNull { textLine -> + // Getting text from the entire line readout, and normalizing to avoid any extra spaces + val lineReadout = textLine.text.trim().replace(" ", "") + val isValid = when (documentType) { + OcrDocumentType.NhisCard -> ghanaNhisCardOcrSelectorUseCase(lineReadout) + OcrDocumentType.GhanaIdCard -> ghanaIdCardOcrSelectorUseCase(lineReadout) + } + if (isValid) { + val blockBoundingRect = textBlock.boundingBox ?: return@firstNotNullOfOrNull null + val lineBoundingRect = textLine.boundingBox ?: return@firstNotNullOfOrNull null + val savedImagePath = saveScannedImageUseCase(bitmap, documentType) + DetectedOcrBlock( + imagePath = savedImagePath, + documentType = documentType, + blockBoundingBox = blockBoundingRect.toBoundingBox(), + lineBoundingBox = lineBoundingRect.toBoundingBox(), + readoutValue = lineReadout, + ) + } else null + } + } + } catch (e: Exception) { + Simber.e("OCR failed for $documentType", e, tag = MULTI_FACTOR_ID) + null + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt new file mode 100644 index 0000000000..396536fc2b --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GetExternalCredentialBasedOnConfidenceUseCase.kt @@ -0,0 +1,47 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import javax.inject.Inject + +internal class GetExternalCredentialBasedOnConfidenceUseCase @Inject constructor() { + + /** + * Constructs the most likely credential string by selecting the most frequent character at each position across all detected OCR + * blocks. This is necessary because during the OCR readouts, detection mechanisms might confuse characters (think, 'l' versus 'I'). + * + * To account for that, this method puts the most frequent character at each position across all readings. + * + * Example: ["ABC", "ACD", "CCD"], credentialLength=3 -> "ACD" + * - Position 0: 'A' appears 2 times, 'C' appears 1 time -> 'A' wins + * - Position 1: 'B' appears 1 time, 'C' appears 2 times -> 'C' wins + * - Position 2: 'C' appears 1 time, 'D' appears 2 times -> 'D' wins + * Result: 'ACD' + * + * Example: ["ABC", "AC"], credentialLength=3 -> "ACD" + * + * @param detectedBlocks list of OCR detection results containing readout values + * @param credentialLength target length of the external credential + * @return most likely credential string based on character frequency voting + * @throws [IllegalArgumentException] if [detectedBlocks] is empty + */ + operator fun invoke(detectedBlocks: List, credentialLength: Int): String { + val detectedValues: List = detectedBlocks + .map(DetectedOcrBlock::readoutValue) + .filter { it.length == credentialLength } + if (detectedValues.isEmpty()) { + throw IllegalArgumentException("OCR block list is empty, cannot extract external credential from it") + } + + // Grouping characters at each position across all strings and picking the most frequent one + return (0 until credentialLength).map { position -> + detectedValues + .map { it[position] } + .groupingBy { it } + .eachCount() + .maxBy { it.value } + .key + }.joinToString(separator = "") + } +} 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 new file mode 100644 index 0000000000..9152a21160 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt @@ -0,0 +1,13 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject + +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" + private val GHANA_ID_PATTERN = Regex("^GHA-\\d{9}-\\d$") + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt new file mode 100644 index 0000000000..634c7277fb --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaNhisCardOcrSelectorUseCase.kt @@ -0,0 +1,15 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import javax.inject.Inject + +internal class GhanaNhisCardOcrSelectorUseCase @Inject constructor() { + + operator fun invoke(readoutValue: String): Boolean = + NHIS_PATTERN.matches(readoutValue) + + companion object { + // NHIS Card membership is 8 digits long + private val NHIS_PATTERN = Regex("^\\d{8}$") + } + +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt new file mode 100644 index 0000000000..61ef0c7262 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/KeepOnlyBestDetectedBlockUseCase.kt @@ -0,0 +1,28 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import javax.inject.Inject + +internal class KeepOnlyBestDetectedBlockUseCase @Inject constructor( + private val getExternalCredentialBasedOnConfidenceUseCase: GetExternalCredentialBasedOnConfidenceUseCase, + private val findBestTextBlockForCredentialUseCase: FindBestTextBlockForCredentialUseCase, + private val deleteScannedImageUseCase: DeleteScannedImageUseCase, +) { + + suspend operator fun invoke(allDetectedBlock: List, documentType: OcrDocumentType): DetectedOcrBlock { + val credentialLength = when(documentType){ + OcrDocumentType.NhisCard -> 8 // NHIS membership number contains 8 digits: 12345678 + OcrDocumentType.GhanaIdCard -> 15 // Ghana ID field contains 15 chars: GHA-123456789-0 + } + val externalCredential = getExternalCredentialBasedOnConfidenceUseCase(allDetectedBlock, credentialLength) + val detectedBlock = findBestTextBlockForCredentialUseCase(credential = externalCredential, detectedBlocks = allDetectedBlock) + + // Deleting cached scan images for all remaining blocks + allDetectedBlock + .map(DetectedOcrBlock::imagePath) + .filterNot { imagePath -> imagePath == detectedBlock.imagePath } + .onEach { imagePath -> deleteScannedImageUseCase(imagePath) } + return detectedBlock + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt new file mode 100644 index 0000000000..3359ef3165 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/NormalizeBitmapToPreviewUseCase.kt @@ -0,0 +1,68 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.Matrix +import javax.inject.Inject +import androidx.core.graphics.scale +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig + +internal class NormalizeBitmapToPreviewUseCase @Inject constructor() { + + /** + * Normalizes a camera capture [inputBitmap] bitmap to match the PreviewView's dimensions and aspect ratio. + * + * This method performs three transformations: + * 1. Rotation - Rotates the bitmap by the specified degrees if needed + * 2. Center cropping - Crops the bitmap to match PreviewView aspect ratio, keeping the center portion + * 3. Scaling - Scales the cropped bitmap to exactly match PreviewView dimensions + * + * The center cropping ensures that the normalized bitmap has the same aspect ratio as what the user + * sees in the camera preview, making OCR results spatially consistent with the preview overlay. + * + * @param inputBitmap the original camera capture bitmap + * @param cropConfig configuration containing rotation, preview width and height + * + * @return normalized bitmap with PreviewView dimensions and aspect ratio + */ + suspend operator fun invoke( + inputBitmap: Bitmap, + cropConfig: OcrCropConfig + ): Bitmap { + val rotationDegrees = cropConfig.rotationDegrees + val previewViewWidth = cropConfig.previewViewWidth + val previewViewHeight = cropConfig.previewViewHeight + + // Rotate if necessary + val rotated = if (rotationDegrees != 0) { + val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } + Bitmap.createBitmap(inputBitmap, 0, 0, inputBitmap.width, inputBitmap.height, matrix, true) + } else inputBitmap + + // Center-crop to match PreviewView aspect ratio + val previewRatio = previewViewWidth.toFloat() / previewViewHeight + val inputRatio = rotated.width.toFloat() / rotated.height + + val cropWidth: Int + val cropHeight: Int + val offsetX: Int + val offsetY: Int + + if (inputRatio > previewRatio) { + cropHeight = rotated.height + cropWidth = (cropHeight * previewRatio).toInt() + offsetX = (rotated.width - cropWidth) / 2 + offsetY = 0 + } else { + cropWidth = rotated.width + cropHeight = (cropWidth / previewRatio).toInt() + offsetX = 0 + offsetY = (rotated.height - cropHeight) / 2 + } + + val cropped = Bitmap.createBitmap(rotated, offsetX, offsetY, cropWidth, cropHeight) + + // Scale to PreviewView size + return cropped.scale(previewViewWidth, previewViewHeight) + } +} + 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 new file mode 100644 index 0000000000..4ca638f4e2 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ProvideCameraListenerUseCase.kt @@ -0,0 +1,53 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import androidx.camera.core.AspectRatio +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.lifecycle.LifecycleOwner +import com.google.common.util.concurrent.ListenableFuture +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports +import javax.inject.Inject + + +@ExcludedFromGeneratedTestCoverageReports("UI Code") +internal class ProvideCameraListenerUseCase @Inject constructor() { + operator fun invoke( + cameraProviderFuture: ListenableFuture, + surfaceProvider: Preview.SurfaceProvider, + viewLifecycleOwner: LifecycleOwner, + onImageAnalysisReady: (ImageAnalysis) -> Unit, + ) = Runnable { + val cameraProvider = cameraProviderFuture.get() + val aspectRatio = AspectRatio.RATIO_16_9 + val preview = Preview.Builder() + .setTargetAspectRatio(aspectRatio) + .build().also { + it.setSurfaceProvider(surfaceProvider) + } + + val imageCapture = ImageCapture.Builder() + .setTargetAspectRatio(aspectRatio) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + val imageAnalysis = ImageAnalysis.Builder() + .setTargetAspectRatio(aspectRatio) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(viewLifecycleOwner, cameraSelector, preview, imageCapture, imageAnalysis) + onImageAnalysisReady(imageAnalysis) + } catch (e: Exception) { + Simber.e("Camera binding failed in OCR", e, MULTI_FACTOR_ID) + } + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/SaveScannedImageUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/SaveScannedImageUseCase.kt new file mode 100644 index 0000000000..39558410e2 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/SaveScannedImageUseCase.kt @@ -0,0 +1,30 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.content.Context +import android.graphics.Bitmap +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject + +internal class SaveScannedImageUseCase @Inject constructor( + @ApplicationContext private val context: Context, +) { + + /** + * Saves a bitmap to the application's cache directory as a JPEG file. + * @param bitmap the bitmap to save + * @return absolute path to the saved file + */ + operator fun invoke(bitmap: Bitmap, documentType: OcrDocumentType): String { + val documentTypeName = documentType.toString().trim().replace(" ", "") + val fileName = "ocr_scan_${documentTypeName}_${System.currentTimeMillis()}.jpg" + val file = File(context.cacheDir, fileName) + + file.outputStream().use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } + + return file.absolutePath + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt index f842b52525..89046b51a6 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -22,13 +22,14 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.tools.extentions.getCurrentPermissionStatus import com.simprints.core.tools.extentions.hasPermission import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanQrBinding import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel -import com.simprints.infra.logging.LoggingConstants +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.CameraHelper @@ -118,6 +119,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext private fun renderInitialState() = with(binding) { permissionRequestView.isVisible = false qrInstructionsText.isVisible = true + qrInstructionsText.text = getString(IDR.string.mfid_scan_instructions, getString(IDR.string.mfid_type_qr_code)) qrPreviewCard.isVisible = false buttonScan.setText(IDR.string.mfid_qr_scan_no_qr_detected) buttonScan.isVisible = true @@ -134,10 +136,15 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext buttonScan.isEnabled = true buttonScan.setOnClickListener { if (viewModel.isValidQrCodeFormat(qrCodeValue)) { - mainViewModel.setExternalCredentialValue(qrCodeValue) + val args = ScannedCredential( + credential = qrCodeValue, + credentialType = ExternalCredentialType.QRCode, + previewImagePath = null, + imageBoundingBox = null + ) findNavController().navigateSafely( this@ExternalCredentialScanQrFragment, - R.id.action_externalCredentialSelectScanQr_to_externalCredentialSearch + ExternalCredentialScanQrFragmentDirections.actionExternalCredentialSelectScanQrToExternalCredentialSearch(args) ) } else { showInvalidQrCodeFormatDialog( @@ -230,10 +237,11 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) = with(binding) { qrInstructionsText.isVisible = false buttonScan.isVisible = false + val bodyText = getString(IDR.string.mfid_scan_camera_permission_body, getString(IDR.string.mfid_type_qr_code)) if (shouldOpenPhoneSettings) { permissionRequestView.init( title = IDR.string.face_capture_permission_denied, - body = IDR.string.login_qr_code_scanning_camera_permission_error, + body = bodyText, buttonText = IDR.string.fingerprint_connect_phone_settings_button, onClickListener = { requireActivity().startActivity( @@ -247,7 +255,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext } else { permissionRequestView.init( title = IDR.string.face_capture_permission_denied, - body = IDR.string.login_qr_code_scanning_camera_permission_error, + body = bodyText, buttonText = IDR.string.face_capture_permission_action, onClickListener = { launchPermissionRequest.launch(CAMERA) diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/view/QrScannerOverlay.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/view/QrScannerOverlay.kt deleted file mode 100644 index 80dfcff514..0000000000 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/view/QrScannerOverlay.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.simprints.feature.externalcredential.screens.scanqr.view - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode -import android.graphics.Rect -import android.util.AttributeSet -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import com.simprints.feature.externalcredential.R -import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports -import com.simprints.infra.resources.R as IDR - -@ExcludedFromGeneratedTestCoverageReports("UI Code") -internal class QrScannerOverlay(context: Context, attrs: AttributeSet? = null) : View(context, attrs) { - private val bgPaint = Paint().apply { - color = ContextCompat.getColor(context, IDR.color.simprints_black) - } - private val clearPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } - private val rect = Rect() - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - (parent as ViewGroup).findViewById(R.id.qrScannerArea)?.let { qrScannerArea -> - canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) - - // geting rect in this view’s coordinates - rect.setEmpty() - qrScannerArea.getGlobalVisibleRect(rect) - val offset = IntArray(2) - getLocationOnScreen(offset) - rect.offset(-offset[0], -offset[1]) - - // shrink each side so that QR code area indicators and at the top - val inset = (8 * resources.displayMetrics.density).toInt() - rect.inset(inset, inset) - - canvas.drawRect(rect, clearPaint) - } - } -} 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 8a46cf5717..36a92affcc 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 @@ -1,9 +1,29 @@ package com.simprints.feature.externalcredential.screens.search +import android.graphics.BitmapFactory +import android.os.Bundle +import android.view.View import androidx.fragment.app.Fragment +import androidx.navigation.fragment.navArgs import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSearchBinding +import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_external_credential_search) { + private val args: ExternalCredentialSearchFragmentArgs by navArgs() + private val binding by viewBinding(FragmentExternalCredentialSearchBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + super.onCreate(savedInstanceState) + val imagePath = args.scannedCredential.previewImagePath!! + loadScannedImage(imagePath) + } + + private fun loadScannedImage(imagePath: String) { + val bitmap = BitmapFactory.decodeFile(imagePath) + binding.scannedImage.setImageBitmap(bitmap) + } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt new file mode 100644 index 0000000000..0018960a31 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/ScannedCredential.kt @@ -0,0 +1,14 @@ +package com.simprints.feature.externalcredential.screens.search.model + +import androidx.annotation.Keep +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.feature.externalcredential.model.BoundingBox +import java.io.Serializable + +@Keep +data class ScannedCredential( + val credential: String, + val credentialType: ExternalCredentialType, + val previewImagePath: String?, + val imageBoundingBox: BoundingBox? +) : Serializable diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt index c449cbc0e2..3e9c0baf75 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt @@ -17,6 +17,7 @@ import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSelectBinding import com.simprints.feature.externalcredential.ext.getQuantityCredentialString import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.select.view.ExternalCredentialTypeAdapter import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber @@ -104,8 +105,8 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext private fun navigateToScanner(type: ExternalCredentialType) { when (type) { - ExternalCredentialType.NHISCard -> startOcr() - ExternalCredentialType.GhanaIdCard -> startOcr() + ExternalCredentialType.NHISCard -> startOcr(OcrDocumentType.NhisCard) + ExternalCredentialType.GhanaIdCard -> startOcr(OcrDocumentType.GhanaIdCard) ExternalCredentialType.QRCode -> startQrScan() } } @@ -149,11 +150,10 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext dialog?.show() } - private fun startOcr() { - // TODO [MS-1163] add OCR parameters to navigation once the OCR fragment is implemented + private fun startOcr(ocrDocumentType: OcrDocumentType) { findNavController().navigateSafely( this, - ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialScanOcr(), + ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialScanOcr(ocrDocumentType), ) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/DocumentScanMaskView.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/DocumentScanMaskView.kt new file mode 100644 index 0000000000..bc83209a44 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/view/DocumentScanMaskView.kt @@ -0,0 +1,100 @@ +package com.simprints.feature.externalcredential.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import com.simprints.feature.externalcredential.R +import com.simprints.infra.resources.R as IDR +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports + +@ExcludedFromGeneratedTestCoverageReports("UI Code") +class DocumentScanMaskView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val bgPaint = Paint() + private val clearPaint = Paint().apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + isAntiAlias = true + } + + private val rect = Rect() + private var targetViewId: Int = NO_ID + private var cornerRadius: Float? = null + private var insetValue: Float? = null + + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.DocumentMaskView, + 0, 0 + ).apply { + try { + targetViewId = getResourceId(R.styleable.DocumentMaskView_targetViewId, NO_ID) + + val backgroundColor = getColor( + R.styleable.DocumentMaskView_maskColor, + ContextCompat.getColor(context, IDR.color.simprints_black) + ) + bgPaint.color = backgroundColor + + if (hasValue(R.styleable.DocumentMaskView_cornerRadius)) { + cornerRadius = getDimension(R.styleable.DocumentMaskView_cornerRadius, 0f) + } + + if (hasValue(R.styleable.DocumentMaskView_inset)) { + insetValue = getDimension(R.styleable.DocumentMaskView_inset, 0f) + } + } finally { + recycle() + } + } + + require(targetViewId != NO_ID) { "targetViewId must be specified" } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + (parent as ViewGroup).findViewById(targetViewId)?.let { targetView -> + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) + + // Calculating target view position relative to this view + rect.setEmpty() + targetView.getGlobalVisibleRect(rect) + val offset = IntArray(2) + getLocationOnScreen(offset) + rect.offset(-offset[0], -offset[1]) + + // Apply inset if specified. This might be useful for QR scanners to create sense of depth + insetValue?.let { inset -> + val insetInt = inset.toInt() + rect.inset(insetInt, insetInt) + } + + // Cutting out central area. Rounded or rectangular, based on cornerRadius + val radius = cornerRadius + if (radius != null && radius > 0f) { + canvas.drawRoundRect( + rect.left.toFloat(), + rect.top.toFloat(), + rect.right.toFloat(), + rect.bottom.toFloat(), + radius, + radius, + clearPaint + ) + } else { + canvas.drawRect(rect, clearPaint) + } + } + } +} diff --git a/feature/external-credential/src/main/res/drawable/viewfinder_border.xml b/feature/external-credential/src/main/res/drawable/viewfinder_border.xml new file mode 100644 index 0000000000..b7822c0547 --- /dev/null +++ b/feature/external-credential/src/main/res/drawable/viewfinder_border.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_ocr.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_ocr.xml index 1f9f2b618b..6366ab997b 100644 --- a/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_ocr.xml +++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_scan_ocr.xml @@ -1,13 +1,119 @@ - - + + + android:layout_height="match_parent" + android:alpha="0.5" + android:clickable="false" + android:focusable="false" + app:cornerRadius="16dp" + app:maskColor="@color/simprints_black" + app:targetViewId="@+id/documentScannerArea" + tools:visibility="visible" /> + + + + + + + + + + + + + + + + + + +