From a81653395501aff4dfaa3d62ab2d2c2867a8fb1e Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Wed, 2 Apr 2025 14:56:40 +0300 Subject: [PATCH 1/6] Added optional function clear() to BaseInteractor --- .../main/kotlin/com/processout/sdk/ui/base/BaseInteractor.kt | 5 ++++- .../processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt | 2 +- .../processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt | 2 +- .../sdk/ui/napm/NativeAlternativePaymentInteractor.kt | 2 +- .../sdk/ui/napm/NativeAlternativePaymentViewModel.kt | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/base/BaseInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/base/BaseInteractor.kt index aa405de78..b01de55b1 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/base/BaseInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/base/BaseInteractor.kt @@ -7,4 +7,7 @@ import kotlinx.coroutines.SupervisorJob internal abstract class BaseInteractor( val interactorScope: POCloseableCoroutineScope = POCloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) -) +) { + + open fun clear() {} +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt index 12700670c..887dd5f50 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt @@ -1117,7 +1117,7 @@ internal class DynamicCheckoutInteractor( } } - fun onCleared() { + override fun clear() { handler.removeCallbacksAndMessages(null) } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt index c50f883b8..d43376863 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt @@ -385,6 +385,6 @@ internal class DynamicCheckoutViewModel private constructor( ) override fun onCleared() { - interactor.onCleared() + interactor.clear() } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt index 573bfc490..d55797bce 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt @@ -951,7 +951,7 @@ internal class NativeAlternativePaymentInteractor( } } - fun onCleared() { + override fun clear() { handler.removeCallbacksAndMessages(null) } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt index a8fdb3fe0..67ad1d222 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt @@ -383,6 +383,6 @@ internal class NativeAlternativePaymentViewModel private constructor( } override fun onCleared() { - interactor.onCleared() + interactor.clear() } } From ac7f7878f947dc4cf1daece869314651eabbf990 Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Thu, 3 Apr 2025 12:14:25 +0300 Subject: [PATCH 2/6] CardRecognitionSession: ensure text recognition module readiness --- .../recognition/CardRecognitionSession.kt | 188 +++++++++++++++--- 1 file changed, 157 insertions(+), 31 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt index b3dd072c8..4fab42c75 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt @@ -1,68 +1,186 @@ package com.processout.sdk.ui.card.scanner.recognition +import android.app.Application import android.graphics.Bitmap import androidx.camera.core.ImageProxy +import com.google.android.gms.common.moduleinstall.InstallStatusListener +import com.google.android.gms.common.moduleinstall.ModuleInstall +import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate.InstallState.* import com.google.mlkit.vision.text.Text import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.processout.sdk.core.POFailure.Code.Generic +import com.processout.sdk.core.ProcessOutResult +import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.tasks.await +import java.io.Closeable internal class CardRecognitionSession( + app: Application, private val numberDetector: CardAttributeDetector, private val expirationDetector: CardAttributeDetector, private val cardholderNameDetector: CardAttributeDetector, - private val shouldScanExpiredCard: Boolean -) { + private val shouldScanExpiredCard: Boolean, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) +) : Closeable { private companion object { const val MIN_CONFIDENCE = 0.8f const val RECOGNITION_DURATION_MS = 3000L } + private val _isReady = MutableStateFlow(false) + val isReady = _isReady.asStateFlow() + private val _currentCard = Channel() val currentCard = _currentCard.receiveAsFlow() - private val _mostFrequentCard = Channel() - val mostFrequentCard = _mostFrequentCard.receiveAsFlow() + private val _result = Channel>() + val result = _result.receiveAsFlow() + private val moduleInstallClient = ModuleInstall.getClient(app) private val textRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) private var startTimestamp = 0L private val recognizedCards = mutableListOf() - suspend fun recognize(imageProxy: ImageProxy) { - val text = textRecognizer.process( - imageProxy.croppedBitmap(), - imageProxy.imageInfo.rotationDegrees - ).await() - val candidates = text.candidates(MIN_CONFIDENCE) - val number = numberDetector.firstMatch(candidates) - if (number != null) { - if (startTimestamp == 0L) { - startTimestamp = System.currentTimeMillis() + init { + ensureReadiness() + } + + private fun ensureReadiness() { + moduleInstallClient + .areModulesAvailable(textRecognizer) + .addOnSuccessListener { + if (it.areModulesAvailable()) { + _isReady.update { true } + } else { + installTextRecognitionModule() + } + }.addOnFailureListener { + send( + ProcessOutResult.Failure( + code = Generic(), + message = "Failed to check text recognition module availability.", + cause = it + ) + ) + }.addOnCanceledListener { + send( + ProcessOutResult.Failure( + code = Generic(), + message = "Checking text recognition module availability has been cancelled." + ) + ) + } + } + + private fun installTextRecognitionModule() { + val request = ModuleInstallRequest.newBuilder() + .addApi(textRecognizer) + .setListener(moduleInstallStatusListener) + .build() + + moduleInstallClient + .installModules(request) + .addOnSuccessListener { + if (it.areModulesAlreadyInstalled()) { + _isReady.update { true } + } + }.addOnFailureListener { + send( + ProcessOutResult.Failure( + code = Generic(), + message = "Request to install text recognition module has failed.", + cause = it + ) + ) + }.addOnCanceledListener { + send( + ProcessOutResult.Failure( + code = Generic(), + message = "Request to install text recognition module has been cancelled." + ) + ) + } + } + + private val moduleInstallStatusListener = object : InstallStatusListener { + override fun onInstallStatusUpdated(status: ModuleInstallStatusUpdate) { + if (isFinal(status.installState)) { + moduleInstallClient.unregisterListener(this) + } + when (status.installState) { + STATE_COMPLETED -> _isReady.update { true } + STATE_FAILED -> send( + ProcessOutResult.Failure( + code = Generic(), + message = "Failed to install text recognition module with the error code: ${status.errorCode}." + ) + ) + STATE_CANCELED -> send( + ProcessOutResult.Failure( + code = Generic(), + message = "Text recognition module installation has been cancelled." + ) + ) } - val card = POScannedCard( - number = number, - expiration = expirationDetector.firstMatch(candidates), - cardholderName = cardholderNameDetector.firstMatch(candidates) - ) - recognizedCards.add(card) - if (!shouldScanExpiredCard && card.expiration?.isExpired == true) { - _currentCard.send(null) - } else { - _currentCard.send(card) + } + + private fun isFinal(@InstallState state: Int): Boolean = + when (state) { + STATE_COMPLETED, + STATE_FAILED, + STATE_CANCELED -> true + else -> false } + } + + fun recognize(imageProxy: ImageProxy) { + if (!_isReady.value) { + imageProxy.close() + return } - if (System.currentTimeMillis() - startTimestamp > RECOGNITION_DURATION_MS) { - if (recognizedCards.isNotEmpty()) { - sendMostFrequentCard() - recognizedCards.clear() + scope.launch { + val text = textRecognizer.process( + imageProxy.croppedBitmap(), + imageProxy.imageInfo.rotationDegrees + ).await() + val candidates = text.candidates(MIN_CONFIDENCE) + val number = numberDetector.firstMatch(candidates) + if (number != null) { + if (startTimestamp == 0L) { + startTimestamp = System.currentTimeMillis() + } + val card = POScannedCard( + number = number, + expiration = expirationDetector.firstMatch(candidates), + cardholderName = cardholderNameDetector.firstMatch(candidates) + ) + recognizedCards.add(card) + if (!shouldScanExpiredCard && card.expiration?.isExpired == true) { + _currentCard.send(null) + } else { + _currentCard.send(card) + } } - startTimestamp = 0L + if (System.currentTimeMillis() - startTimestamp > RECOGNITION_DURATION_MS) { + if (recognizedCards.isNotEmpty()) { + sendMostFrequentCard() + recognizedCards.clear() + } + startTimestamp = 0L + } + imageProxy.close() } - imageProxy.close() } private fun ImageProxy.croppedBitmap() = @@ -102,7 +220,7 @@ internal class CardRecognitionSession( if (!shouldScanExpiredCard && card.expiration?.isExpired == true) { return } - return _mostFrequentCard.send(card) + _result.send(ProcessOutResult.Success(card)) } private fun List.mostFrequent(): T? = @@ -110,4 +228,12 @@ internal class CardRecognitionSession( .eachCount() .maxByOrNull { it.value } ?.key + + private fun send(failure: ProcessOutResult.Failure) { + scope.launch { _result.send(failure) } + } + + override fun close() { + scope.cancel() + } } From 3eae62d8d4445e60deb31f49696dacc3aa24b374 Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Thu, 3 Apr 2025 12:20:52 +0300 Subject: [PATCH 3/6] Added loading state to interactor and VM --- .../sdk/ui/card/scanner/CardScannerInteractorState.kt | 1 + .../sdk/ui/card/scanner/CardScannerViewModel.kt | 10 ++++++++-- .../sdk/ui/card/scanner/CardScannerViewModelState.kt | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt index ced759b0d..88e935a01 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt @@ -3,6 +3,7 @@ package com.processout.sdk.ui.card.scanner import com.processout.sdk.ui.card.scanner.recognition.POScannedCard internal data class CardScannerInteractorState( + val loading: Boolean, val isCameraPermissionGranted: Boolean, val isTorchEnabled: Boolean, val currentCard: POScannedCard? diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt index d39d077d1..454790a08 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt @@ -33,6 +33,7 @@ internal class CardScannerViewModel( configuration = configuration, interactor = CardScannerInteractor( cardRecognitionSession = CardRecognitionSession( + app = app, numberDetector = CardNumberDetector(), expirationDetector = CardExpirationDetector(), cardholderNameDetector = CardholderNameDetector(), @@ -57,12 +58,13 @@ internal class CardScannerViewModel( private fun map(state: CardScannerInteractorState) = with(configuration) { CardScannerViewModelState( + loading = state.loading, + isCameraPermissionGranted = state.isCameraPermissionGranted, title = title ?: app.getString(R.string.po_card_scanner_title), description = description ?: app.getString(R.string.po_card_scanner_description), currentCard = state.currentCard, torchAction = torchAction(state.isTorchEnabled), - cancelAction = cancelButton?.toAction(), - isCameraPermissionGranted = state.isCameraPermissionGranted + cancelAction = cancelButton?.toAction() ) } @@ -97,4 +99,8 @@ internal class CardScannerViewModel( ) } ) + + override fun onCleared() { + interactor.clear() + } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt index 6b91300bc..6f1fff9d6 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt @@ -4,10 +4,11 @@ import com.processout.sdk.ui.card.scanner.recognition.POScannedCard import com.processout.sdk.ui.core.state.POActionState internal data class CardScannerViewModelState( + val loading: Boolean, + val isCameraPermissionGranted: Boolean, val title: String, val description: String, val currentCard: POScannedCard?, val torchAction: POActionState, - val cancelAction: POActionState?, - val isCameraPermissionGranted: Boolean + val cancelAction: POActionState? ) From 1289fcbc74eb159e251f513d1ca655c4a5204812 Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Thu, 3 Apr 2025 12:42:57 +0300 Subject: [PATCH 4/6] Logs --- .../scanner/recognition/CardRecognitionSession.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt index 4fab42c75..4c580267a 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt @@ -14,6 +14,7 @@ import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions import com.processout.sdk.core.POFailure.Code.Generic import com.processout.sdk.core.ProcessOutResult +import com.processout.sdk.core.logger.POLogger import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -93,6 +94,7 @@ internal class CardRecognitionSession( .installModules(request) .addOnSuccessListener { if (it.areModulesAlreadyInstalled()) { + POLogger.info("Text recognition module is already installed.") _isReady.update { true } } }.addOnFailureListener { @@ -111,6 +113,7 @@ internal class CardRecognitionSession( ) ) } + POLogger.info("Requested to install text recognition module.") } private val moduleInstallStatusListener = object : InstallStatusListener { @@ -119,7 +122,10 @@ internal class CardRecognitionSession( moduleInstallClient.unregisterListener(this) } when (status.installState) { - STATE_COMPLETED -> _isReady.update { true } + STATE_COMPLETED -> { + POLogger.info("Text recognition module has been installed successfully.") + _isReady.update { true } + } STATE_FAILED -> send( ProcessOutResult.Failure( code = Generic(), @@ -230,7 +236,10 @@ internal class CardRecognitionSession( ?.key private fun send(failure: ProcessOutResult.Failure) { - scope.launch { _result.send(failure) } + scope.launch { + POLogger.info("Failure: %s", failure) + _result.send(failure) + } } override fun close() { From 84099d134b5e5789fd342ba4b161a84e32866eee Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Thu, 3 Apr 2025 13:47:26 +0300 Subject: [PATCH 5/6] Loader --- .../sdk/ui/card/scanner/CardScannerScreen.kt | 12 ++++++++++-- .../ui/card/scanner/POCardScannerConfiguration.kt | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt index d9e41e5c3..32e674bd8 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt @@ -229,7 +229,14 @@ private fun CameraPreview( onRelease = { cameraController.unbind() } ) } else { - Box(modifier.background(Color.Black)) + Box( + modifier = modifier.background(Color.Black), + contentAlignment = Alignment.Center + ) { + if (state.loading) { + POCircularProgressIndicator.Large(color = Color.White) + } + } } ScannedCard( card = state.currentCard, @@ -394,7 +401,8 @@ internal object CardScannerScreen { width = border.widthDp.dp, color = colorResource(id = border.colorResId) ), - overlayColor = colorResource(id = overlayColorResId) + overlayColor = overlayColorResId?.let { colorResource(id = it) } + ?: defaultCameraPreview.overlayColor ) private val defaultCard: CardStyle diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt index 00b139083..b11987cda 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt @@ -82,7 +82,7 @@ data class POCardScannerConfiguration( data class CameraPreviewStyle( val border: POBorderStyle, @ColorRes - val overlayColorResId: Int + val overlayColorResId: Int? = null ) : Parcelable /** From 5b1cbceac5adad2869a2ce0d43b7ab4d44228448 Mon Sep 17 00:00:00 2001 From: Vitalii Vanziak Date: Thu, 3 Apr 2025 17:18:00 +0300 Subject: [PATCH 6/6] Collect session state and handle result --- .../ui/card/scanner/CardScannerInteractor.kt | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt index 02b2dc611..7ee758864 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt @@ -4,6 +4,8 @@ import com.processout.sdk.core.POFailure.Code.Cancelled import com.processout.sdk.core.POFailure.Code.Generic import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.core.logger.POLogger +import com.processout.sdk.core.onFailure +import com.processout.sdk.core.onSuccess import com.processout.sdk.ui.base.BaseInteractor import com.processout.sdk.ui.card.scanner.CardScannerCompletion.* import com.processout.sdk.ui.card.scanner.CardScannerEvent.* @@ -23,7 +25,7 @@ internal class CardScannerInteractor( ) : BaseInteractor() { private companion object { - const val INIT_DELAY_MS = 500L + const val INIT_DELAY_MS = 400L } private val _completion = MutableStateFlow(Awaiting) @@ -37,25 +39,33 @@ internal class CardScannerInteractor( init { POLogger.info("Starting card scanner.") + collectSessionState() collectRecognizedCards() - interactorScope.launch { - delay(INIT_DELAY_MS) - _sideEffects.send(CameraPermissionRequest) - } } private fun initState() = CardScannerInteractorState( + loading = false, isCameraPermissionGranted = false, isTorchEnabled = false, currentCard = null ) + private fun collectSessionState() { + interactorScope.launch { + cardRecognitionSession.isReady.collect { isReady -> + _state.update { it.copy(loading = !isReady) } + if (isReady) { + delay(INIT_DELAY_MS) + _sideEffects.send(CameraPermissionRequest) + } + } + } + } + fun onEvent(event: CardScannerEvent) { when (event) { is CameraPermissionResult -> handle(event) - is ImageAnalysis -> interactorScope.launch { - cardRecognitionSession.recognize(event.imageProxy) - } + is ImageAnalysis -> cardRecognitionSession.recognize(event.imageProxy) is TorchToggle -> { POLogger.debug("Torch toggle: ${event.isEnabled}.") _state.update { it.copy(isTorchEnabled = event.isEnabled) } @@ -91,8 +101,12 @@ internal class CardScannerInteractor( } } interactorScope.launch(Dispatchers.Main.immediate) { - cardRecognitionSession.mostFrequentCard.collect { card -> - _completion.update { Success(card) } + cardRecognitionSession.result.collect { result -> + result.onSuccess { card -> + _completion.update { Success(card) } + }.onFailure { failure -> + cancel(failure) + } } } } @@ -101,4 +115,8 @@ internal class CardScannerInteractor( POLogger.info("Cancelled: %s", failure) _completion.update { Failure(failure) } } + + override fun clear() { + cardRecognitionSession.close() + } }