diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index 91c7634400..fc98c9ea09 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -12,6 +12,9 @@ dependencies { implementation(project(":infra:config-sync")) implementation(project(":infra:ui-base")) implementation(project(":feature:exit-form")) + implementation(project(":infra:enrolment-records:repository")) + implementation(project(":infra:auth-store")) + implementation(project(":infra:matching")) implementation(libs.androidX.cameraX.view) implementation(libs.mlkit.text.recognition) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt index 84d5f78768..2aeb320d87 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialContract.kt @@ -1,13 +1,28 @@ package com.simprints.feature.externalcredential +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import com.simprints.infra.config.store.models.AgeGroup +import com.simprints.infra.matching.MatchParams +@ExcludedFromGeneratedTestCoverageReports("Navigation class") object ExternalCredentialContract { val DESTINATION = R.id.externalCredentialControllerFragment fun getParams( subjectId: String?, flowType: FlowType, - ) = ExternalCredentialParams(subjectId = subjectId, flowType = flowType) + ageGroup: AgeGroup?, + probeReferenceId: String? = null, + faceSamples: List = emptyList(), + fingerprintSamples: List = emptyList(), + ) = ExternalCredentialParams( + subjectId = subjectId, + flowType = flowType, + ageGroup = ageGroup, + probeReferenceId = probeReferenceId, + faceSamples = faceSamples, + fingerprintSamples = fingerprintSamples, + ) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt new file mode 100644 index 0000000000..8942e3b815 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ExternalCredentialSearchResult.kt @@ -0,0 +1,26 @@ +package com.simprints.feature.externalcredential + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.step.StepResult +import com.simprints.feature.externalcredential.model.CredentialMatch +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential + +/** + * Results of the external credential 1:L match (where L is '1' or really close to 1). + * + * @param flowType flow type. Either [FlowType.ENROL] or [FlowType.IDENTIFY] + * @param scannedCredential information about the credential that was scanned + * @param matchResults if [scannedCredential] exists in local database, this field contains match results between the biometric probe taken + * during the flow, and probes linked to the [scannedCredential] + */ +@Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class ExternalCredentialSearchResult( + val flowType: FlowType, + val scannedCredential: ScannedCredential, + val matchResults: List, +) : StepResult { + val goodMatches = matchResults.filter(CredentialMatch::isVerificationSuccessful) +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ViewExt.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ViewExt.kt new file mode 100644 index 0000000000..5e85668ca3 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ViewExt.kt @@ -0,0 +1,44 @@ +package com.simprints.feature.externalcredential.ext + +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import com.simprints.infra.uibase.annotations.ExcludedFromGeneratedTestCoverageReports + +@ExcludedFromGeneratedTestCoverageReports("View animation") +fun View.fadeOut( + duration: Long, + scaleX: Boolean, + fragment: Fragment, +) = animate() + .alpha(0f) + .setDuration(duration) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + if (fragment.isAdded) { + this.isVisible = false + } + }.also { + if (scaleX) { + it.scaleX(0f) + } + it.start() + } + +@ExcludedFromGeneratedTestCoverageReports("View animation") +fun View.fadeIn( + duration: Long, + fragment: Fragment, + onComplete: (() -> Unit)?, +) = animate() + .alpha(1f) + .setInterpolator(AccelerateInterpolator()) + .setDuration(duration) + .withEndAction { + if (fragment.isAdded) { + this.isVisible = true + onComplete?.invoke() + } + }.also { it.start() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/CredentialMatch.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/CredentialMatch.kt new file mode 100644 index 0000000000..a628729173 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/CredentialMatch.kt @@ -0,0 +1,20 @@ +package com.simprints.feature.externalcredential.model + +import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.FingerprintConfiguration +import com.simprints.infra.matching.MatchResultItem + +@Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") +data class CredentialMatch( + val credential: TokenizableString.Tokenized, + val matchResult: MatchResultItem, + val verificationThreshold: Float, + val faceBioSdk: FaceConfiguration.BioSdk?, + val fingerprintBioSdk: FingerprintConfiguration.BioSdk?, +) { + val isVerificationSuccessful = matchResult.confidence >= verificationThreshold +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt index ee33721d4d..54060a72c9 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/model/ExternalCredentialParams.kt @@ -1,11 +1,19 @@ package com.simprints.feature.externalcredential.model import androidx.annotation.Keep +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.step.StepParams +import com.simprints.infra.config.store.models.AgeGroup +import com.simprints.infra.matching.MatchParams @Keep +@ExcludedFromGeneratedTestCoverageReports("Data class") data class ExternalCredentialParams( val subjectId: String?, val flowType: FlowType, + val ageGroup: AgeGroup?, + val probeReferenceId: String?, + val faceSamples: List, + val fingerprintSamples: List, ) : StepParams diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt index 79f1970523..9c6f7dda13 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.exitform.ExitFormContract import com.simprints.feature.exitform.ExitFormResult import com.simprints.feature.externalcredential.GraphExternalCredentialInternalDirections @@ -17,7 +18,6 @@ import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.navigation.navigationParams import dagger.hilt.android.AndroidEntryPoint -import kotlin.getValue @AndroidEntryPoint internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment_external_credential_controller) { @@ -65,6 +65,12 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment private fun initObservers() { viewModel.stateLiveData.observe(viewLifecycleOwner) { } + viewModel.finishEvent.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver { result -> + findNavController().finishWithResult(this, result) + }, + ) } private fun initListeners() { diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index 6a9d824c1c..26d6f57c89 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.core.livedata.LiveDataEventWithContent +import com.simprints.core.livedata.send +import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -12,6 +15,11 @@ import com.simprints.infra.resources.R as IDR @HiltViewModel internal class ExternalCredentialViewModel @Inject internal constructor() : ViewModel() { private var isInitialized = false + lateinit var params: ExternalCredentialParams + private set + val finishEvent: LiveData> + get() = _finishEvent + private val _finishEvent = MutableLiveData>() private var state: ExternalCredentialState = ExternalCredentialState.EMPTY set(value) { field = value @@ -35,6 +43,7 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View fun init(params: ExternalCredentialParams) { if (!isInitialized) { isInitialized = true + this.params = params updateState { ExternalCredentialState.EMPTY.copy(subjectId = params.subjectId, flowType = params.flowType) } } } @@ -45,4 +54,14 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View ExternalCredentialType.QRCode -> IDR.string.mfid_type_qr_code null -> IDR.string.mfid_type_any_document } + + fun mapTypeToCredentialFieldResource(type: ExternalCredentialType) = when (type) { + ExternalCredentialType.NHISCard -> IDR.string.mfid_nhis_card_credential_field + ExternalCredentialType.GhanaIdCard -> IDR.string.mfid_ghana_id_credential_field + ExternalCredentialType.QRCode -> IDR.string.mfid_qr_credential_field + } + + fun finish(result: ExternalCredentialSearchResult) { + _finishEvent.send(result) + } } 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 2bc74976f1..9ceff794a7 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 @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import android.provider.Settings import android.view.View +import android.view.ViewPropertyAnimator import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.ImageAnalysis import androidx.camera.lifecycle.ProcessCameraProvider @@ -21,17 +22,16 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.simprints.core.DispatcherBG -import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.permission.PermissionStatus import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.core.tools.extentions.getCurrentPermissionStatus import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanOcrBinding +import com.simprints.feature.externalcredential.ext.fadeIn +import com.simprints.feature.externalcredential.ext.fadeOut import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel -import com.simprints.feature.externalcredential.screens.scanocr.model.DetectedOcrBlock import com.simprints.feature.externalcredential.screens.scanocr.model.OcrCropConfig -import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.usecase.BuildOcrCropConfigUseCase import com.simprints.feature.externalcredential.screens.scanocr.usecase.ProvideCameraListenerUseCase import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential @@ -77,6 +77,10 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex private var previousPermissionStatus: PermissionStatus? = null private lateinit var cameraExecutor: ExecutorService private lateinit var imageAnalysis: ImageAnalysis + private var progressAnimator: ViewPropertyAnimator? = null + private var checkAnimator: ViewPropertyAnimator? = null + private var isAnimatingCompletion: Boolean = false + private var pendingFinishAction: (() -> Unit)? = null @Inject lateinit var viewModelFactory: ExternalCredentialScanOcrViewModel.Factory @@ -127,9 +131,17 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex override fun onDestroy() { stopOcr() stopCamera() + clearAnimations() super.onDestroy() } + private fun clearAnimations() { + pendingFinishAction = null + isAnimatingCompletion = false + checkAnimator?.cancel() + progressAnimator?.cancel() + } + private fun initializeFragment() { renderInitialState() initCamera(onComplete = { @@ -151,13 +163,14 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } ScanOcrState.NotScanning -> renderInitialState() + ScanOcrState.Complete -> animateCompletionState() } } viewModel.finishOcrEvent.observe( viewLifecycleOwner, - LiveDataEventWithContentObserver { detectedBlock -> - finish(detectedBlock) + LiveDataEventWithContentObserver { scannedCredential -> + scheduleFinish(scannedCredential) }, ) } @@ -184,8 +197,13 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex private fun renderProgress(state: ScanOcrState.ScanningInProgress) = with(binding) { val progressPercentage = (state.successfulCaptures * 100 / state.scansRequired).coerceAtMost(100) buttonScan.isVisible = false - progressCard.isVisible = true - progressBar.progress = progressPercentage + progressContainer.isVisible = true + progressBar.isVisible = true + iconScanComplete.alpha = 0f + progressBar.setProgressCompat(progressPercentage, true) + instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_black)) + viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_white)) + viewfinderMask.alpha = VIEW_FINDER_ALPHA_SCAN_ACTIVE } private fun renderInitialState() = with(binding) { @@ -193,19 +211,34 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex permissionRequestView.isVisible = false instructionsText.isVisible = true instructionsText.text = getString(IDR.string.mfid_scan_instructions, documentTypeText) + instructionsText.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white)) documentScannerArea.isVisible = true - progressCard.isVisible = false + progressContainer.isVisible = false buttonScan.isVisible = true buttonScan.setOnClickListener { viewModel.ocrStarted() startOcr() } + viewfinderMask.setMaskColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_black)) + viewfinderMask.alpha = VIEW_FINDER_ALPHA_INITIAL + } + + private fun animateCompletionState() = with(binding) { + isAnimatingCompletion = true + progressBar.fadeOut(FINISH_ANIMATION_DURATION, scaleX = true, fragment = this@ExternalCredentialScanOcrFragment) + scanInstructions.fadeOut(FINISH_ANIMATION_DURATION, scaleX = false, fragment = this@ExternalCredentialScanOcrFragment) + iconScanComplete.fadeIn(FINISH_ANIMATION_DURATION, fragment = this@ExternalCredentialScanOcrFragment, onComplete = { + isAnimatingCompletion = false + // Execute any pending action after the animation. Currently used is for next fragment navigation + pendingFinishAction?.invoke() + pendingFinishAction = null + }) } private fun renderNoPermission(shouldOpenPhoneSettings: Boolean) { with(binding) { instructionsText.isVisible = false - progressCard.isVisible = false + progressContainer.isVisible = false documentScannerArea.isInvisible = true buttonScan.isVisible = false val documentTypeText = viewModel.getDocumentTypeRes().run(::getString) @@ -273,20 +306,31 @@ internal class ExternalCredentialScanOcrFragment : Fragment(R.layout.fragment_ex } } - private fun finish(detectedBlock: DetectedOcrBlock) { - val credentialType = when (detectedBlock.documentType) { - OcrDocumentType.NhisCard -> ExternalCredentialType.NHISCard - OcrDocumentType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard + /** + * Waits until all animations are complete before navigating away. Completion animations are in place because the execution of + * [ExternalCredentialScanOcrViewModel.processOcrResultsAndFinish] is not immediate, and it makes the transition to the next fragment + * smoother for user. + * + * The animation state is stored in the [isAnimatingCompletion]. If it is set to true, the navigation action is set to + * [pendingFinishAction] which will be executed once animations are complete. If false, the navigation will proceed immediately. + */ + private fun scheduleFinish(credential: ScannedCredential) { + val navigationAction = { + findNavController().navigateSafely( + this@ExternalCredentialScanOcrFragment, + ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(credential), + ) } - val args = ScannedCredential( - credential = detectedBlock.readoutValue, - credentialType = credentialType, - previewImagePath = detectedBlock.imagePath, - imageBoundingBox = detectedBlock.blockBoundingBox, - ) - findNavController().navigateSafely( - this@ExternalCredentialScanOcrFragment, - ExternalCredentialScanOcrFragmentDirections.actionExternalCredentialScanOcrToExternalCredentialSearch(args), - ) + if (isAnimatingCompletion) { + pendingFinishAction = navigationAction + } else { + navigationAction.invoke() + } + } + + companion object { + private const val VIEW_FINDER_ALPHA_INITIAL = 0.5f + private const val VIEW_FINDER_ALPHA_SCAN_ACTIVE = 0.9f + private const val FINISH_ANIMATION_DURATION = 300L } } 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 index 5911ef61d3..82390d17f0 100644 --- 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 @@ -6,15 +6,27 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherBG +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.domain.tokenization.asTokenizableRaw 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.model.asExternalCredentialType 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.feature.externalcredential.screens.scanocr.usecase.SaveScannedImageUseCase +import com.simprints.feature.externalcredential.screens.scanocr.usecase.SaveScannedImageUseCase.ScanImageType.ZoomedInCredential +import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOntoCredentialUseCase +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.resources.R import dagger.assisted.Assisted @@ -29,6 +41,11 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( private val cropDocumentFromPreviewUseCase: CropDocumentFromPreviewUseCase, private val getCredentialCoordinatesUseCase: GetCredentialCoordinatesUseCase, private val keepOnlyBestDetectedBlockUseCase: KeepOnlyBestDetectedBlockUseCase, + private val saveScannedImageUseCase: SaveScannedImageUseCase, + private val zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase, + private val tokenizationProcessor: TokenizationProcessor, + private val authStore: AuthStore, + private val configManager: ConfigManager, @DispatcherBG private val bgDispatcher: CoroutineDispatcher, ) : ViewModel() { @AssistedFactory @@ -48,9 +65,9 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } private val _stateLiveData = MutableLiveData() val stateLiveData: LiveData = _stateLiveData - val finishOcrEvent: LiveData> + val finishOcrEvent: LiveData> get() = _finishOcrEvent - private val _finishOcrEvent = MutableLiveData>() + private val _finishOcrEvent = MutableLiveData>() private fun updateState(state: (ScanOcrState) -> ScanOcrState) { this.state = state(this.state) @@ -97,14 +114,45 @@ internal class ExternalCredentialScanOcrViewModel @AssistedInject constructor( } fun processOcrResultsAndFinish() { + updateState { ScanOcrState.Complete } viewModelScope.launch { + val project = configManager.getProject(authStore.signedInProjectId) val detectedBlock = keepOnlyBestDetectedBlockUseCase(detectedBlocks, ocrDocumentType) - _finishOcrEvent.send(detectedBlock) + val credentialType = detectedBlock.documentType.asExternalCredentialType() + val blockBoundingBox = detectedBlock.blockBoundingBox + val zoomedCredentialImagePath = buildZoomedImagePath(detectedBlock) + val credential = tokenizationProcessor.encrypt( + decrypted = detectedBlock.readoutValue.asTokenizableRaw(), + tokenKeyType = TokenKeyType.ExternalCredential, + project = project, + ) as TokenizableString.Tokenized + val scannedCredential = ScannedCredential( + credential = credential, + credentialType = credentialType, + documentImagePath = detectedBlock.imagePath, + zoomedCredentialImagePath = zoomedCredentialImagePath, + credentialBoundingBox = blockBoundingBox, + ) + _finishOcrEvent.send(scannedCredential) detectedBlocks = emptyList() - updateState { ScanOcrState.NotScanning } } } + private fun buildZoomedImagePath(detectedBlock: DetectedOcrBlock): String? = try { + saveScannedImageUseCase( + bitmap = zoomOntoCredentialUseCase(detectedBlock.imagePath, detectedBlock.blockBoundingBox), + documentType = detectedBlock.documentType, + imageType = ZoomedInCredential, + ) + } catch (e: Exception) { + Simber.e( + "Unable to zoom into bounding box [${detectedBlock.blockBoundingBox}] of ${detectedBlock.documentType} image ${detectedBlock.imagePath}", + e, + MULTI_FACTOR_ID, + ) + null + } + fun ocrOnFrameStarted() { isRunningOcrOnFrame = true } 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 index 170d8d4b07..0c14d4ef8d 100644 --- 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 @@ -11,6 +11,8 @@ internal sealed class ScanOcrState { val scansRequired: Int, ) : ScanOcrState() + data object Complete : ScanOcrState() + companion object { val EMPTY = ScanOcrState.NotScanning } 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 index 65c7a33fe1..46c6e8cca5 100644 --- 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 @@ -1,6 +1,19 @@ package com.simprints.feature.externalcredential.screens.scanocr.model +import com.simprints.core.domain.externalcredential.ExternalCredentialType + enum class OcrDocumentType { NhisCard, GhanaIdCard, } + +fun ExternalCredentialType.asOcrDocumentType() = when (this) { + ExternalCredentialType.NHISCard -> OcrDocumentType.NhisCard + ExternalCredentialType.GhanaIdCard -> OcrDocumentType.GhanaIdCard + ExternalCredentialType.QRCode -> throw IllegalArgumentException("Cannot create Ocr Document Type from $this") +} + +fun OcrDocumentType.asExternalCredentialType() = when (this) { + OcrDocumentType.NhisCard -> ExternalCredentialType.NHISCard + OcrDocumentType.GhanaIdCard -> ExternalCredentialType.GhanaIdCard +} 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 index b376308335..5b0f2d4979 100644 --- 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 @@ -17,6 +17,12 @@ internal class CropDocumentFromPreviewUseCase @Inject constructor() { val width = right - left val height = bottom - top + if (width <= 0 || height <= 0) { + throw IllegalStateException( + "Invalid OCR crop dimensions: width=$width, height=$height. CutoutRect=[$cutoutRect], bitmapSize(w,h)=[${bitmap.width}x${bitmap.height}]", + ) + } + return Bitmap.createBitmap(bitmap, left, top, width, 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 index d7b8980bef..942abe575f 100644 --- 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 @@ -9,6 +9,7 @@ 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.feature.externalcredential.screens.scanocr.usecase.SaveScannedImageUseCase.ScanImageType.FullDocument import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import javax.inject.Inject @@ -60,7 +61,7 @@ internal class GetCredentialCoordinatesUseCase @Inject constructor( if (isValid) { val blockBoundingRect = textBlock.boundingBox ?: return@firstNotNullOfOrNull null val lineBoundingRect = textLine.boundingBox ?: return@firstNotNullOfOrNull null - val savedImagePath = saveScannedImageUseCase(bitmap, documentType) + val savedImagePath = saveScannedImageUseCase(bitmap, documentType, imageType = FullDocument) DetectedOcrBlock( imagePath = savedImagePath, documentType = documentType, 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 index 01c53a6392..b9af489940 100644 --- 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 @@ -18,9 +18,10 @@ internal class SaveScannedImageUseCase @Inject constructor( operator fun invoke( bitmap: Bitmap, documentType: OcrDocumentType, + imageType: ScanImageType, ): String { val documentTypeName = documentType.toString().trim().replace(" ", "") - val fileName = "ocr_scan_${documentTypeName}_${System.currentTimeMillis()}.jpg" + val fileName = "ocr_scan_${documentTypeName}_${imageType}_${System.currentTimeMillis()}.jpg" val file = File(context.cacheDir, fileName) file.outputStream().use { outputStream -> @@ -29,4 +30,9 @@ internal class SaveScannedImageUseCase @Inject constructor( return file.absolutePath } + + enum class ScanImageType { + FullDocument, + ZoomedInCredential, + } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt new file mode 100644 index 0000000000..ac7c11bea4 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/ZoomOntoCredentialUseCase.kt @@ -0,0 +1,87 @@ +package com.simprints.feature.externalcredential.screens.scanocr.usecase + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.simprints.feature.externalcredential.model.BoundingBox +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min + +internal class ZoomOntoCredentialUseCase @Inject constructor() { + /** + * Zooms into given image. Zoom area defined by the [boundingBox] + * + * @param imagePath path to image containing the document to zoom into + * @param boundingBox bounding box that defines the zoom area + * @return zoomed-in bitmap + */ + operator fun invoke( + imagePath: String, + boundingBox: BoundingBox, + ): Bitmap { + val bitmap = BitmapFactory.decodeFile(imagePath) + val expandedBox = scaleBoundingBox(boundingBox, BOX_SCALE_FACTOR) + + val left = expandedBox.left.coerceIn(0, bitmap.width) + val top = expandedBox.top.coerceIn(0, bitmap.height) + val right = expandedBox.right.coerceIn(left, bitmap.width) + val bottom = expandedBox.bottom.coerceIn(top, bitmap.height) + val boxWidth = right - left + val boxHeight = bottom - top + + if (boxWidth <= 0 || boxHeight <= 0) { + return bitmap + } + + val boxAspectRatio = boxWidth.toFloat() / boxHeight.toFloat() + val finalWidth: Int + val finalHeight: Int + // Bounding box is taller/wider than 16:10, adding padding to left/right or top/bottom + if (boxAspectRatio > TARGET_ASPECT_RATIO) { + finalWidth = boxWidth + finalHeight = (boxWidth / TARGET_ASPECT_RATIO).toInt() + } else { + finalHeight = boxHeight + finalWidth = (boxHeight * TARGET_ASPECT_RATIO).toInt() + } + + val extraWidth = finalWidth - boxWidth + val extraHeight = finalHeight - boxHeight + val cropRight = min(bitmap.width, right + extraWidth / 2) + val cropBottom = min(bitmap.height, bottom + extraHeight / 2) + + // Adjust if we hit image boundaries + val adjustedLeft = max(0, cropRight - finalWidth) + val adjustedTop = max(0, cropBottom - finalHeight) + val actualWidth = cropRight - adjustedLeft + val actualHeight = cropBottom - adjustedTop + return Bitmap.createBitmap(bitmap, adjustedLeft, adjustedTop, actualWidth, actualHeight) + } + + /** + * Expands a bounding box by a percentage on all sides. + * + * @param box Original bounding box + * @return Expanded bounding box that may exceed image bounds as it is addressed later + */ + private fun scaleBoundingBox( + box: BoundingBox, + scale: Float, + ): BoundingBox { + val boxWidth = box.right - box.left + val boxHeight = box.bottom - box.top + val horizontalExpansion = (boxWidth * (scale - 1f) / 2f).toInt() + val verticalExpansion = (boxHeight * (scale - 1f) / 2f).toInt() + return BoundingBox( + left = box.left - horizontalExpansion, + top = box.top - verticalExpansion, + right = box.right + horizontalExpansion, + bottom = box.bottom + verticalExpansion, + ) + } + + companion object { + private const val TARGET_ASPECT_RATIO = 16f / 10f + private const val BOX_SCALE_FACTOR = 1.15f + } +} 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 5460284735..2f02b6e221 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 @@ -23,6 +23,7 @@ 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.domain.tokenization.TokenizableString import com.simprints.core.tools.extentions.getCurrentPermissionStatus import com.simprints.core.tools.extentions.hasPermission import com.simprints.core.tools.extentions.permissionFromResult @@ -129,19 +130,20 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext } private fun renderScanComplete(state: ScanQrState.QrCodeCaptured) = with(binding) { - val qrCodeValue = state.qrCodeValue + val qrCodeRaw = state.qrCode qrInstructionsText.isVisible = false qrPreviewCard.isVisible = true - qrPreviewText.text = state.qrCodeValue + qrPreviewText.text = qrCodeRaw.value buttonScan.setText(IDR.string.mfid_continue) buttonScan.isEnabled = true buttonScan.setOnClickListener { - if (viewModel.isValidQrCodeFormat(qrCodeValue)) { + if (viewModel.isValidQrCodeFormat(qrCodeRaw)) { val args = ScannedCredential( - credential = qrCodeValue, + credential = state.qrCodeEncrypted, credentialType = ExternalCredentialType.QRCode, - previewImagePath = null, - imageBoundingBox = null, + documentImagePath = null, + credentialBoundingBox = null, + zoomedCredentialImagePath = null, ) findNavController().navigateSafely( this@ExternalCredentialScanQrFragment, @@ -149,7 +151,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext ) } else { showInvalidQrCodeFormatDialog( - qrCodeValue = qrCodeValue, + qrCodeValue = qrCodeRaw, onDismiss = { dismissDialog() viewModel.updateCapturedValue(null) @@ -160,7 +162,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext } private fun showInvalidQrCodeFormatDialog( - qrCodeValue: String, + qrCodeValue: TokenizableString.Raw, onDismiss: () -> Unit, ) { dismissDialog() @@ -170,7 +172,7 @@ internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_ext .also { view -> val qrValueTextView = view.findViewById(R.id.qrValue) val buttonRescan = view.findViewById