From a7d1e6042a5ffb19d89d26790ce4ca0e9fdf3433 Mon Sep 17 00:00:00 2001 From: Ujjwal Kumar Date: Fri, 17 May 2024 18:16:07 +0530 Subject: [PATCH] TP-65206 | QR Scanner performance improvement (#10691) Co-authored-by: Shaurya Rehan Co-authored-by: Shivam Goyal --- android/gradle/libs.versions.toml | 5 +- android/navi-pay/build.gradle | 1 + .../scanpay/ui/QrScannerScreen.kt | 206 +++++++++++++----- .../scanpay/viewmodel/QrScannerViewModel.kt | 8 +- 4 files changed, 157 insertions(+), 63 deletions(-) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 720c321569..30f1264d79 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -20,7 +20,8 @@ androidx-annotation = "1.7.1" androidx-appcompat = "1.6.1" androidx-arch-core-testing = "2.2.0" androidx-browser = "1.3.0" -androidx-camera = "1.3.0-beta01" +androidx-camera = "1.3.3" +androidx-camera-mlkit = "1.3.0-beta02" androidx-constraintlayout = "2.1.4" androidx-constraintlayoutCompose = "1.1.0-alpha10" androidx-core-ktx = "1.8.0" @@ -160,7 +161,7 @@ androidx-browser = { module = "androidx.browser:browser", version.ref = "android androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } -androidx-camera-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision", version.ref = "androidx-camera" } +androidx-camera-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision", version.ref = "androidx-camera-mlkit" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } diff --git a/android/navi-pay/build.gradle b/android/navi-pay/build.gradle index 049129c7a1..b6994c5be7 100644 --- a/android/navi-pay/build.gradle +++ b/android/navi-pay/build.gradle @@ -78,6 +78,7 @@ dependencies { implementation libs.android.play.core.ktx implementation libs.androidx.appcompat implementation libs.androidx.camera.mlkit.vision + implementation libs.androidx.compose.runtime.livedata implementation libs.androidx.constraintlayout implementation libs.androidx.core.google.shortcuts implementation libs.androidx.core.ktx diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt index e748618f34..30abc5a260 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/ui/QrScannerScreen.kt @@ -7,17 +7,21 @@ package com.navi.pay.management.moneytransfer.scanpay.ui -import android.util.Size +import android.view.ScaleGestureDetector import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera +import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.ZoomState +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.lifecycle.ExperimentalCameraProviderConfiguration +import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.mlkit.vision.MlKitAnalyzer -import androidx.camera.view.CameraController -import androidx.camera.view.CameraController.IMAGE_ANALYSIS -import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -38,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow @@ -58,15 +63,16 @@ import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LiveData import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE import com.google.mlkit.vision.common.InputImage import com.navi.alfred.AlfredManager import com.navi.base.utils.EMPTY +import com.navi.common.utils.log import com.navi.design.font.FontWeightEnum import com.navi.design.snackbar.NaviSnackBar import com.navi.design.snackbar.SnackBarConfig @@ -112,6 +118,7 @@ import com.ramcosta.composedestinations.result.ResultRecipient import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +@androidx.annotation.OptIn(ExperimentalCameraProviderConfiguration::class) @Destination @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterialApi::class) @Composable @@ -135,12 +142,6 @@ fun QrScannerScreen( Unit } - val options = remember { - BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() - } - - val barcodeScanner = remember { BarcodeScanning.getClient(options) } - val qrImageErrorViewEnable by qrScannerViewModel.qrImageErrorViewEnable.collectAsStateWithLifecycle() val rewardsNudgeDetailEntity by @@ -151,7 +152,11 @@ fun QrScannerScreen( uri?.let { try { val image = InputImage.fromFilePath(naviPayActivity, uri) - barcodeScanner + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(FORMAT_QR_CODE) + .build() + ) .process(image) .addOnSuccessListener { barcodes -> if ((barcodes != null) && barcodes.size > 0) { @@ -236,7 +241,7 @@ fun QrScannerScreen( } } - val isTorchEnabled by qrScannerViewModel.isTorchEnabled.collectAsStateWithLifecycle() + val isTorchEnabled by qrScannerViewModel.isTorchEnabled.observeAsState(false) val showPopUpPermission by qrScannerViewModel.showPopUpPermission.collectAsStateWithLifecycle() val cameraPermissionsState = @@ -403,10 +408,9 @@ fun QrScannerScreen( if (cameraPermissionsState.allPermissionsGranted) { QrCamera( naviPayAnalytics = naviPayAnalytics, - isTorchEnabled = isTorchEnabled, + isTorchEnabled = qrScannerViewModel.isTorchEnabled, naviPayActivity = naviPayActivity, onScanSuccess = qrScannerViewModel::processQrContent, - barcodeScanner = barcodeScanner, naviPaySessionId = qrScannerViewModel.getNaviPaySessionId() ) } @@ -494,13 +498,13 @@ fun QrScannerScreen( } } +@ExperimentalCameraProviderConfiguration @Composable private fun QrCamera( - isTorchEnabled: Boolean, + isTorchEnabled: LiveData, naviPayActivity: NaviPayActivity, onScanSuccess: (String) -> Unit, naviPayAnalytics: NaviPayAnalytics.NaviPayQrScanner, - barcodeScanner: BarcodeScanner, naviPaySessionId: String? ) { val context = LocalContext.current @@ -520,51 +524,137 @@ private fun QrCamera( previewView } - val cameraController = remember { - LifecycleCameraController(naviPayActivity.baseContext).apply { - cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - isPinchToZoomEnabled = true - isTapToFocusEnabled = true - imageAnalysisTargetSize = CameraController.OutputSize(Size(2600, 2160)) - imageAnalysisBackpressureStrategy = ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST - setEnabledUseCases(IMAGE_ANALYSIS) - setLinearZoom(0.3f) - setImageAnalysisAnalyzer( - ContextCompat.getMainExecutor(naviPayActivity.baseContext), - MlKitAnalyzer( - listOf(barcodeScanner), - CameraController.COORDINATE_SYSTEM_VIEW_REFERENCED, - ContextCompat.getMainExecutor(naviPayActivity.baseContext) - ) { result: MlKitAnalyzer.Result -> - val barcodeResults = result.getValue(barcodeScanner) - if ((barcodeResults != null) && barcodeResults.size > 0) { - onScanSuccess(barcodeResults.first().rawValue.toString()) - naviPayAnalytics.onQRReceived( - isFromGallery = false, - qrCount = barcodeResults.size, - naviPaySessionId = naviPaySessionId - ) - } - } - ) - - bindToLifecycle(lifecycleOwner) - previewView.controller = this - } - } - - LaunchedEffect(cameraController, isTorchEnabled) { - if (cameraController.cameraInfo?.hasFlashUnit() == true) { - cameraController.cameraControl?.enableTorch(isTorchEnabled) - } - } - AndroidView( - factory = { previewView }, + factory = { + ProcessCameraProvider.getInstance(naviPayActivity.applicationContext).apply { + addListener( + { + val cameraProvider = this.get() + + val cameraSelector = + CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + val barcodeScanner = + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(FORMAT_QR_CODE) + .build() + ) + + val imageAnalyzer = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .setResolutionSelector( + ResolutionSelector.Builder() + .setAllowedResolutionMode( + ResolutionSelector + .PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE + ) + .build() + ) + .build() + .also { + it.setAnalyzer( + ContextCompat.getMainExecutor(naviPayActivity.baseContext), + MlKitAnalyzer( + listOf(barcodeScanner), + ImageAnalysis.COORDINATE_SYSTEM_ORIGINAL, + ContextCompat.getMainExecutor( + naviPayActivity.baseContext + ) + ) { result: MlKitAnalyzer.Result -> + val barcodeResults = result.getValue(barcodeScanner) + if ( + barcodeResults != null && + barcodeResults.size > 0 && + !barcodeResults.first().rawValue.isNullOrEmpty() + ) { + onScanSuccess( + barcodeResults.first().rawValue.toString() + ) + naviPayAnalytics.onQRReceived( + isFromGallery = false, + qrCount = barcodeResults.size, + naviPaySessionId = naviPaySessionId + ) + } + } + ) + } + + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + var camera: Camera? = null + + try { + cameraProvider.unbindAll() + camera = + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalyzer + ) + } catch (e: Exception) { + e.log() + } + + val scaleGestureDetector = + ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + updateZoom( + scaleFactor = detector.scaleFactor, + cameraControl = camera?.cameraControl, + zoomState = camera?.cameraInfo?.zoomState?.value + ) + return true + } + } + ) + previewView.setOnTouchListener { _, event -> + scaleGestureDetector.onTouchEvent(event) + true + } + + isTorchEnabled.observe(lifecycleOwner) { isTorchEnabled -> + if (camera?.cameraInfo?.hasFlashUnit() == true) { + camera.cameraControl.enableTorch(isTorchEnabled) + } + } + }, + ContextCompat.getMainExecutor(context) + ) + } + previewView + }, modifier = Modifier.fillMaxSize().background(color = NaviPayColor.darkGray) ) } +private fun updateZoom(scaleFactor: Float, cameraControl: CameraControl?, zoomState: ZoomState?) { + if (cameraControl == null || zoomState == null) { + return + } + + val currentZoomRatio = zoomState.zoomRatio + val newZoomRatio = currentZoomRatio * scaleFactor + + // Ensure zoom stays within valid bounds + val minZoomRatio = zoomState.minZoomRatio + val maxZoomRatio = zoomState.maxZoomRatio + val adjustedZoomRatio = minZoomRatio.coerceAtLeast(newZoomRatio.coerceAtMost(maxZoomRatio)) + + cameraControl.setZoomRatio(adjustedZoomRatio) +} + @Composable private fun TorchButton(isTorchEnabled: Boolean, onTorchToggle: () -> Unit) { Image( diff --git a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt index 60852acd97..08fd54f5f6 100644 --- a/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt +++ b/android/navi-pay/src/main/kotlin/com/navi/pay/management/moneytransfer/scanpay/viewmodel/QrScannerViewModel.kt @@ -7,6 +7,8 @@ package com.navi.pay.management.moneytransfer.scanpay.viewmodel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.navi.base.utils.ResourceProvider import com.navi.common.network.models.isSuccessWithData @@ -77,8 +79,8 @@ constructor( private val _qrImageErrorViewEnable = MutableStateFlow(false) val qrImageErrorViewEnable = _qrImageErrorViewEnable.asStateFlow() - private val _isTorchEnabled = MutableStateFlow(false) - val isTorchEnabled = _isTorchEnabled.asStateFlow() + private val _isTorchEnabled = MutableLiveData(false) + val isTorchEnabled = _isTorchEnabled as LiveData var isQrCodeProcessing: AtomicBoolean = AtomicBoolean(false) @@ -223,7 +225,7 @@ constructor( } fun toggleTorchStatus() { - viewModelScope.launch { _isTorchEnabled.update { !isTorchEnabled.value } } + viewModelScope.launch { _isTorchEnabled.value = !(isTorchEnabled.value ?: false) } } fun setImageErrorView(qrImageErrorViewEnable: Boolean) {