diff --git a/WordPress/build.gradle b/WordPress/build.gradle index c911d5d2c816..23c5d870e4ed 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -423,8 +423,14 @@ dependencies { implementation "com.google.android.play:review:$googlePlayReviewVersion" implementation "com.google.android.play:review-ktx:$googlePlayReviewVersion" implementation "com.google.android.gms:play-services-auth:$googlePlayServicesAuthVersion" - implementation "com.google.android.gms:play-services-code-scanner:$googlePlayServicesCodeScannerVersion" - implementation "com.google.mlkit:barcode-scanning-common:$googleMLKitBarcodeScanningVersion" + implementation "com.google.mlkit:barcode-scanning-common:$googleMLKitBarcodeScanningCommonVersion" + implementation "com.google.mlkit:text-recognition:$googleMLKitTextRecognitionVersion" + implementation "com.google.mlkit:barcode-scanning:$googleMLKitBarcodeScanningVersion" + + // CameraX + implementation "androidx.camera:camera-camera2:$androidxCameraVersion" + implementation "androidx.camera:camera-lifecycle:$androidxCameraVersion" + implementation "androidx.camera:camera-view:$androidxCameraVersion" implementation "com.android.installreferrer:installreferrer:$androidInstallReferrerVersion" implementation "com.github.chrisbanes:PhotoView:$chrisbanesPhotoviewVersion" diff --git a/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt new file mode 100644 index 000000000000..4584657f8a11 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.modules + +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.ui.barcodescanner.CodeScanner +import org.wordpress.android.ui.barcodescanner.GoogleBarcodeFormatMapper +import org.wordpress.android.ui.barcodescanner.GoogleCodeScannerErrorMapper +import org.wordpress.android.ui.barcodescanner.GoogleMLKitCodeScanner +import org.wordpress.android.ui.barcodescanner.MediaImageProvider + +@InstallIn(SingletonComponent::class) +@Module +class CodeScannerModule { + @Provides + @Reusable + fun provideGoogleCodeScanner( + barcodeScanner: BarcodeScanner, + googleCodeScannerErrorMapper: GoogleCodeScannerErrorMapper, + barcodeFormatMapper: GoogleBarcodeFormatMapper, + inputImageProvider: MediaImageProvider, + ): CodeScanner { + return GoogleMLKitCodeScanner( + barcodeScanner, + googleCodeScannerErrorMapper, + barcodeFormatMapper, + inputImageProvider, + ) + } + + @Provides + @Reusable + fun providesGoogleBarcodeScanner() = BarcodeScanning.getClient() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt new file mode 100644 index 000000000000..e5df791ee239 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.ui.barcodescanner + +import android.content.res.Configuration +import android.util.Size +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST +import androidx.camera.core.ImageProxy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.wordpress.android.ui.compose.theme.AppTheme +import androidx.camera.core.Preview as CameraPreview + +@Composable +fun BarcodeScanner( + codeScanner: CodeScanner, + onScannedResult: (Flow) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { + ProcessCameraProvider.getInstance(context) + } + Column( + modifier = Modifier.fillMaxSize() + ) { + AndroidView( + factory = { context -> + val previewView = PreviewView(context) + val preview = CameraPreview.Builder().build() + preview.setSurfaceProvider(previewView.surfaceProvider) + val selector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() + val imageAnalysis = ImageAnalysis.Builder().setTargetResolution( + Size( + previewView.width, + previewView.height + ) + ) + .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) + .build() + imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> + onScannedResult(codeScanner.startScan(imageProxy)) + } + try { + cameraProviderFuture.get().bindToLifecycle(lifecycleOwner, selector, preview, imageAnalysis) + } catch (e: IllegalStateException) { + onScannedResult( + flowOf( + CodeScannerStatus.Failure( + e.message + ?: "Illegal state exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + ) + ) + ) + } catch (e: IllegalArgumentException) { + onScannedResult( + flowOf( + CodeScannerStatus.Failure( + e.message + ?: "Illegal argument exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + ) + ) + ) + } + previewView + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +class DummyCodeScanner : CodeScanner { + override fun startScan(imageProxy: ImageProxy): Flow { + return flowOf(CodeScannerStatus.Success("", GoogleBarcodeFormatMapper.BarcodeFormat.FormatUPCA)) + } +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BarcodeScannerScreenPreview() { + AppTheme { + BarcodeScanner(codeScanner = DummyCodeScanner(), onScannedResult = {}) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt new file mode 100644 index 000000000000..01a270db9ece --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt @@ -0,0 +1,146 @@ +package org.wordpress.android.ui.barcodescanner + +import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun BarcodeScannerScreen( + codeScanner: CodeScanner, + permissionState: BarcodeScanningViewModel.PermissionState, + onResult: (Boolean) -> Unit, + onScannedResult: (Flow) -> Unit, +) { + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + onResult(granted) + }, + ) + LaunchedEffect(key1 = Unit) { + cameraPermissionLauncher.launch(BarcodeScanningFragment.KEY_CAMERA_PERMISSION) + } + when (permissionState) { + BarcodeScanningViewModel.PermissionState.Granted -> { + BarcodeScanner( + codeScanner = codeScanner, + onScannedResult = onScannedResult + ) + } + is BarcodeScanningViewModel.PermissionState.ShouldShowRationale -> { + AlertDialog( + title = stringResource(id = permissionState.title), + message = stringResource(id = permissionState.message), + ctaLabel = stringResource(id = permissionState.ctaLabel), + dismissCtaLabel = stringResource(id = permissionState.dismissCtaLabel), + ctaAction = { permissionState.ctaAction.invoke(cameraPermissionLauncher) }, + dismissCtaAction = { permissionState.dismissCtaAction.invoke() } + ) + } + is BarcodeScanningViewModel.PermissionState.PermanentlyDenied -> { + AlertDialog( + title = stringResource(id = permissionState.title), + message = stringResource(id = permissionState.message), + ctaLabel = stringResource(id = permissionState.ctaLabel), + dismissCtaLabel = stringResource(id = permissionState.dismissCtaLabel), + ctaAction = { permissionState.ctaAction.invoke(cameraPermissionLauncher) }, + dismissCtaAction = { permissionState.dismissCtaAction.invoke() } + ) + } + BarcodeScanningViewModel.PermissionState.Unknown -> { + // no-op + } + } +} + +@Composable +private fun AlertDialog( + title: String, + message: String, + ctaLabel: String, + dismissCtaLabel: String, + ctaAction: () -> Unit, + dismissCtaAction: () -> Unit, +) { + AlertDialog( + onDismissRequest = { dismissCtaAction() }, + title = { + Text(title) + }, + text = { + Text(message) + }, + confirmButton = { + TextButton( + onClick = { + ctaAction() + } + ) { + Text( + ctaLabel, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(8.dp) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + dismissCtaAction() + } + ) { + Text( + dismissCtaLabel, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(8.dp) + ) + } + }, + ) +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun DeniedOnceAlertDialog() { + AppTheme { + AlertDialog( + title = stringResource(id = R.string.barcode_scanning_alert_dialog_title), + message = stringResource(id = R.string.barcode_scanning_alert_dialog_rationale_message), + ctaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_rationale_cta_label), + dismissCtaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_dismiss_label), + ctaAction = {}, + dismissCtaAction = {}, + ) + } +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun DeniedPermanentlyAlertDialog() { + AppTheme { + AlertDialog( + title = stringResource(id = R.string.barcode_scanning_alert_dialog_title), + message = stringResource(id = R.string.barcode_scanning_alert_dialog_permanently_denied_message), + ctaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_permanently_denied_cta_label), + dismissCtaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_dismiss_label), + ctaAction = {}, + dismissCtaAction = {}, + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt new file mode 100644 index 000000000000..a05fd7c67769 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt @@ -0,0 +1,102 @@ +package org.wordpress.android.ui.barcodescanner + +import android.Manifest +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.util.WPPermissionUtils +import javax.inject.Inject + +@AndroidEntryPoint +class BarcodeScanningFragment : Fragment() { + private val viewModel: BarcodeScanningViewModel by viewModels() + + @Inject + lateinit var codeScanner: GoogleMLKitCodeScanner + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = + ComposeView(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view as ComposeView + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + observeCameraPermissionState(view) + observeViewModelEvents() + initBackPressHandler() + } + + private fun observeCameraPermissionState(view: ComposeView) { + viewModel.permissionState.observe(viewLifecycleOwner) { permissionState -> + view.setContent { + AppTheme { + BarcodeScannerScreen( + codeScanner = codeScanner, + permissionState = permissionState, + onResult = { granted -> + viewModel.updatePermissionState( + granted, + shouldShowRequestPermissionRationale(KEY_CAMERA_PERMISSION) + ) + }, + onScannedResult = { codeScannerStatus -> + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + codeScannerStatus.collect { status -> + setResultAndPopStack(status) + } + } + } + }, + ) + } + } + } + } + private fun observeViewModelEvents() { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is BarcodeScanningViewModel.ScanningEvents.LaunchCameraPermission -> { + event.cameraLauncher.launch(KEY_CAMERA_PERMISSION) + } + + is BarcodeScanningViewModel.ScanningEvents.OpenAppSettings -> { + WPPermissionUtils.showAppSettings(requireContext()) + } + + is BarcodeScanningViewModel.ScanningEvents.Exit -> { + setResultAndPopStack(CodeScannerStatus.Exit) + } + } + } + } + + private fun initBackPressHandler() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + setResultAndPopStack(CodeScannerStatus.NavigateUp) } + } + + private fun setResultAndPopStack(status: CodeScannerStatus) { + setFragmentResult(KEY_BARCODE_SCANNING_REQUEST, bundleOf(KEY_BARCODE_SCANNING_SCAN_STATUS to status)) + requireActivity().supportFragmentManager.popBackStackImmediate() + } + + companion object { + const val KEY_BARCODE_SCANNING_SCAN_STATUS = "barcode_scanning_scan_status" + const val KEY_BARCODE_SCANNING_REQUEST = "key_barcode_scanning_request" + const val KEY_CAMERA_PERMISSION = Manifest.permission.CAMERA + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt new file mode 100644 index 000000000000..c6455ddeed40 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt @@ -0,0 +1,37 @@ +package org.wordpress.android.ui.barcodescanner + +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import javax.inject.Inject + +class BarcodeScanningTracker @Inject constructor( + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper +) { + fun trackScanFailure(source: ScanningSource, type: CodeScanningErrorType) { + analyticsTrackerWrapper.track( + AnalyticsTracker.Stat.BARCODE_SCANNING_FAILURE, + mapOf( + KEY_SCANNING_SOURCE to source.source, + KEY_SCANNING_FAILURE_REASON to type.toString(), + ) + ) + } + + fun trackSuccess(source: ScanningSource) { + analyticsTrackerWrapper.track( + AnalyticsTracker.Stat.BARCODE_SCANNING_SUCCESS, + mapOf( + KEY_SCANNING_SOURCE to source.source + ) + ) + } + + companion object { + const val KEY_SCANNING_SOURCE = "source" + const val KEY_SCANNING_FAILURE_REASON = "scanning_failure_reason" + } +} + +enum class ScanningSource(val source: String) { + QRCODE_LOGIN("qrcode_login") +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt new file mode 100644 index 000000000000..532bc34f3ccd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.R +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.viewmodel.ScopedViewModel +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class BarcodeScanningViewModel @Inject constructor( + @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher +) : ScopedViewModel(bgDispatcher) { + private val _permissionState = MutableLiveData() + val permissionState: LiveData = _permissionState + + private val _event: MutableLiveData = MutableLiveData() + val event: LiveData = _event + + init { + _permissionState.value = PermissionState.Unknown + } + + fun updatePermissionState( + isPermissionGranted: Boolean, + shouldShowRequestPermissionRationale: Boolean, + ) { + when { + isPermissionGranted -> { + // display scanning screen + _permissionState.value = PermissionState.Granted + } + + // It will launch events that some place can response to + shouldShowRequestPermissionRationale -> { + // Denied once, ask to grant camera permission + _permissionState.value = PermissionState.ShouldShowRationale( + title = R.string.barcode_scanning_alert_dialog_title, + message = R.string.barcode_scanning_alert_dialog_rationale_message, + ctaLabel = R.string.barcode_scanning_alert_dialog_rationale_cta_label, + dismissCtaLabel = R.string.barcode_scanning_alert_dialog_dismiss_label, + ctaAction = { _event.value = ScanningEvents.LaunchCameraPermission(it) }, + dismissCtaAction = { + _event.value = (ScanningEvents.Exit) + } + ) + } + + else -> { + // Permanently denied, ask to enable permission from the app settings + _permissionState.value = PermissionState.PermanentlyDenied( + title = R.string.barcode_scanning_alert_dialog_title, + message = R.string.barcode_scanning_alert_dialog_permanently_denied_message, + ctaLabel = R.string.barcode_scanning_alert_dialog_permanently_denied_cta_label, + dismissCtaLabel = R.string.barcode_scanning_alert_dialog_dismiss_label, + ctaAction = { + _event.value = ScanningEvents.OpenAppSettings(it) + }, + dismissCtaAction = { + _event.value = (ScanningEvents.Exit) + } + ) + } + } + } + + sealed class ScanningEvents { + data class LaunchCameraPermission( + val cameraLauncher: ManagedActivityResultLauncher + ) : ScanningEvents() + + data class OpenAppSettings( + val cameraLauncher: ManagedActivityResultLauncher + ) : ScanningEvents() + + object Exit : ScanningEvents() + } + + sealed class PermissionState { + object Granted : PermissionState() + + data class ShouldShowRationale( + @StringRes val title: Int, + @StringRes val message: Int, + @StringRes val ctaLabel: Int, + @StringRes val dismissCtaLabel: Int, + val ctaAction: (ManagedActivityResultLauncher) -> Unit, + val dismissCtaAction: () -> Unit, + ) : PermissionState() + + data class PermanentlyDenied( + @StringRes val title: Int, + @StringRes val message: Int, + @StringRes val ctaLabel: Int, + @StringRes val dismissCtaLabel: Int, + val ctaAction: (ManagedActivityResultLauncher) -> Unit, + val dismissCtaAction: () -> Unit, + ) : PermissionState() + + object Unknown : PermissionState() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt new file mode 100644 index 000000000000..6dde760e0bf0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt @@ -0,0 +1,92 @@ +package org.wordpress.android.ui.barcodescanner + +import android.os.Parcelable +import androidx.camera.core.ImageProxy +import kotlinx.coroutines.flow.Flow +import kotlinx.parcelize.Parcelize + +interface CodeScanner { + fun startScan(imageProxy: ImageProxy): Flow +} + +sealed class CodeScannerStatus : Parcelable { + @Parcelize + data class Success(val code: String, val format: GoogleBarcodeFormatMapper.BarcodeFormat) : CodeScannerStatus() + @Parcelize + data class Failure( + val error: String?, + val type: CodeScanningErrorType + ) : CodeScannerStatus() + @Parcelize + data object NavigateUp : CodeScannerStatus() + @Parcelize + data object Exit : CodeScannerStatus() +} + +sealed class CodeScanningErrorType : Parcelable { + @Parcelize + object Aborted : CodeScanningErrorType() + @Parcelize + object AlreadyExists : CodeScanningErrorType() + @Parcelize + object Cancelled : CodeScanningErrorType() + @Parcelize + object CodeScannerAppNameUnavailable : CodeScanningErrorType() + @Parcelize + object CodeScannerCameraPermissionNotGranted : CodeScanningErrorType() + @Parcelize + object CodeScannerCancelled : CodeScanningErrorType() + @Parcelize + object CodeScannerGooglePlayServicesVersionTooOld : CodeScanningErrorType() + @Parcelize + object CodeScannerPipelineInferenceError : CodeScanningErrorType() + @Parcelize + object CodeScannerPipelineInitializationError : CodeScanningErrorType() + @Parcelize + object CodeScannerTaskInProgress : CodeScanningErrorType() + @Parcelize + object CodeScannerUnavailable : CodeScanningErrorType() + @Parcelize + object DataLoss : CodeScanningErrorType() + @Parcelize + object DeadlineExceeded : CodeScanningErrorType() + @Parcelize + object FailedPrecondition : CodeScanningErrorType() + @Parcelize + object Internal : CodeScanningErrorType() + @Parcelize + object InvalidArgument : CodeScanningErrorType() + @Parcelize + object ModelHashMismatch : CodeScanningErrorType() + @Parcelize + object ModelIncompatibleWithTFLite : CodeScanningErrorType() + @Parcelize + object NetworkIssue : CodeScanningErrorType() + @Parcelize + object NotEnoughSpace : CodeScanningErrorType() + @Parcelize + object NotFound : CodeScanningErrorType() + @Parcelize + object OutOfRange : CodeScanningErrorType() + @Parcelize + object PermissionDenied : CodeScanningErrorType() + @Parcelize + object ResourceExhausted : CodeScanningErrorType() + @Parcelize + object UnAuthenticated : CodeScanningErrorType() + @Parcelize + object UnAvailable : CodeScanningErrorType() + @Parcelize + object UnImplemented : CodeScanningErrorType() + @Parcelize + object Unknown : CodeScanningErrorType() + @Parcelize + data class Other(val throwable: Throwable?) : CodeScanningErrorType() + + override fun toString(): String = when (this) { + is Other -> this.throwable?.message ?: "Other" + else -> this.javaClass.run { + name.removePrefix("${`package`?.name ?: ""}.") + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt new file mode 100644 index 000000000000..ef7f9973f399 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.ui.barcodescanner + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import javax.inject.Inject +import com.google.mlkit.vision.barcode.common.Barcode as GoogleBarcode + +class GoogleBarcodeFormatMapper @Inject constructor() { + @Suppress("ComplexMethod") + fun mapBarcodeFormat(format: Int): BarcodeFormat { + return when (format) { + GoogleBarcode.FORMAT_AZTEC -> BarcodeFormat.FormatAztec + GoogleBarcode.FORMAT_CODABAR -> BarcodeFormat.FormatCodaBar + GoogleBarcode.FORMAT_CODE_128 -> BarcodeFormat.FormatCode128 + GoogleBarcode.FORMAT_CODE_39 -> BarcodeFormat.FormatCode39 + GoogleBarcode.FORMAT_CODE_93 -> BarcodeFormat.FormatCode93 + GoogleBarcode.FORMAT_DATA_MATRIX -> BarcodeFormat.FormatDataMatrix + GoogleBarcode.FORMAT_EAN_13 -> BarcodeFormat.FormatEAN13 + GoogleBarcode.FORMAT_EAN_8 -> BarcodeFormat.FormatEAN8 + GoogleBarcode.FORMAT_ITF -> BarcodeFormat.FormatITF + GoogleBarcode.FORMAT_PDF417 -> BarcodeFormat.FormatPDF417 + GoogleBarcode.FORMAT_QR_CODE -> BarcodeFormat.FormatQRCode + GoogleBarcode.FORMAT_UPC_A -> BarcodeFormat.FormatUPCA + GoogleBarcode.FORMAT_UPC_E -> BarcodeFormat.FormatUPCE + GoogleBarcode.FORMAT_UNKNOWN -> BarcodeFormat.FormatUnknown + else -> BarcodeFormat.FormatUnknown + } + } + + sealed class BarcodeFormat(val formatName: String) : Parcelable { + @Parcelize + object FormatAztec : BarcodeFormat("aztec") + @Parcelize + object FormatCodaBar : BarcodeFormat("codabar") + @Parcelize + object FormatCode128 : BarcodeFormat("code_128") + @Parcelize + object FormatCode39 : BarcodeFormat("code_39") + @Parcelize + object FormatCode93 : BarcodeFormat("code_93") + @Parcelize + object FormatDataMatrix : BarcodeFormat("data_matrix") + @Parcelize + object FormatEAN13 : BarcodeFormat("ean_13") + @Parcelize + object FormatEAN8 : BarcodeFormat("ean_8") + @Parcelize + object FormatITF : BarcodeFormat("itf") + @Parcelize + object FormatPDF417 : BarcodeFormat("pdf_417") + @Parcelize + object FormatQRCode : BarcodeFormat("qr_code") + @Parcelize + object FormatUPCA : BarcodeFormat("upc_a") + @Parcelize + object FormatUPCE : BarcodeFormat("upc_e") + @Parcelize + object FormatUnknown : BarcodeFormat("unknown") + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt new file mode 100644 index 000000000000..b8c8721f10b5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt @@ -0,0 +1,74 @@ +package org.wordpress.android.ui.barcodescanner + +import com.google.mlkit.common.MlKitException +import com.google.mlkit.common.MlKitException.ABORTED +import com.google.mlkit.common.MlKitException.ALREADY_EXISTS +import com.google.mlkit.common.MlKitException.CANCELLED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_APP_NAME_UNAVAILABLE +import com.google.mlkit.common.MlKitException.CODE_SCANNER_CAMERA_PERMISSION_NOT_GRANTED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_CANCELLED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD +import com.google.mlkit.common.MlKitException.CODE_SCANNER_PIPELINE_INFERENCE_ERROR +import com.google.mlkit.common.MlKitException.CODE_SCANNER_PIPELINE_INITIALIZATION_ERROR +import com.google.mlkit.common.MlKitException.CODE_SCANNER_TASK_IN_PROGRESS +import com.google.mlkit.common.MlKitException.CODE_SCANNER_UNAVAILABLE +import com.google.mlkit.common.MlKitException.DATA_LOSS +import com.google.mlkit.common.MlKitException.DEADLINE_EXCEEDED +import com.google.mlkit.common.MlKitException.FAILED_PRECONDITION +import com.google.mlkit.common.MlKitException.INTERNAL +import com.google.mlkit.common.MlKitException.INVALID_ARGUMENT +import com.google.mlkit.common.MlKitException.MODEL_HASH_MISMATCH +import com.google.mlkit.common.MlKitException.MODEL_INCOMPATIBLE_WITH_TFLITE +import com.google.mlkit.common.MlKitException.NETWORK_ISSUE +import com.google.mlkit.common.MlKitException.NOT_ENOUGH_SPACE +import com.google.mlkit.common.MlKitException.NOT_FOUND +import com.google.mlkit.common.MlKitException.OUT_OF_RANGE +import com.google.mlkit.common.MlKitException.PERMISSION_DENIED +import com.google.mlkit.common.MlKitException.RESOURCE_EXHAUSTED +import com.google.mlkit.common.MlKitException.UNAUTHENTICATED +import com.google.mlkit.common.MlKitException.UNAVAILABLE +import com.google.mlkit.common.MlKitException.UNIMPLEMENTED +import com.google.mlkit.common.MlKitException.UNKNOWN +import javax.inject.Inject + +class GoogleCodeScannerErrorMapper @Inject constructor() { + @Suppress("ComplexMethod") + fun mapGoogleMLKitScanningErrors( + exception: Throwable? + ): CodeScanningErrorType { + return when ((exception as? MlKitException)?.errorCode) { + ABORTED -> CodeScanningErrorType.Aborted + ALREADY_EXISTS -> CodeScanningErrorType.AlreadyExists + CANCELLED -> CodeScanningErrorType.Cancelled + CODE_SCANNER_APP_NAME_UNAVAILABLE -> CodeScanningErrorType.CodeScannerAppNameUnavailable + CODE_SCANNER_CAMERA_PERMISSION_NOT_GRANTED -> + CodeScanningErrorType.CodeScannerCameraPermissionNotGranted + CODE_SCANNER_CANCELLED -> CodeScanningErrorType.CodeScannerCancelled + CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD -> + CodeScanningErrorType.CodeScannerGooglePlayServicesVersionTooOld + CODE_SCANNER_PIPELINE_INFERENCE_ERROR -> CodeScanningErrorType.CodeScannerPipelineInferenceError + CODE_SCANNER_PIPELINE_INITIALIZATION_ERROR -> + CodeScanningErrorType.CodeScannerPipelineInitializationError + CODE_SCANNER_TASK_IN_PROGRESS -> CodeScanningErrorType.CodeScannerTaskInProgress + CODE_SCANNER_UNAVAILABLE -> CodeScanningErrorType.CodeScannerUnavailable + DATA_LOSS -> CodeScanningErrorType.DataLoss + DEADLINE_EXCEEDED -> CodeScanningErrorType.DeadlineExceeded + FAILED_PRECONDITION -> CodeScanningErrorType.FailedPrecondition + INTERNAL -> CodeScanningErrorType.Internal + INVALID_ARGUMENT -> CodeScanningErrorType.InvalidArgument + MODEL_HASH_MISMATCH -> CodeScanningErrorType.ModelHashMismatch + MODEL_INCOMPATIBLE_WITH_TFLITE -> CodeScanningErrorType.ModelIncompatibleWithTFLite + NETWORK_ISSUE -> CodeScanningErrorType.NetworkIssue + NOT_ENOUGH_SPACE -> CodeScanningErrorType.NotEnoughSpace + NOT_FOUND -> CodeScanningErrorType.NotFound + OUT_OF_RANGE -> CodeScanningErrorType.OutOfRange + PERMISSION_DENIED -> CodeScanningErrorType.PermissionDenied + RESOURCE_EXHAUSTED -> CodeScanningErrorType.ResourceExhausted + UNAUTHENTICATED -> CodeScanningErrorType.UnAuthenticated + UNAVAILABLE -> CodeScanningErrorType.UnAvailable + UNIMPLEMENTED -> CodeScanningErrorType.UnImplemented + UNKNOWN -> CodeScanningErrorType.Unknown + else -> CodeScanningErrorType.Other(exception) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt new file mode 100644 index 000000000000..2e0d339c473e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.common.Barcode +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +class GoogleMLKitCodeScanner @Inject constructor( + private val barcodeScanner: BarcodeScanner, + private val errorMapper: GoogleCodeScannerErrorMapper, + private val barcodeFormatMapper: GoogleBarcodeFormatMapper, + private val inputImageProvider: MediaImageProvider, +) : CodeScanner { + private var barcodeFound = false + @androidx.camera.core.ExperimentalGetImage + override fun startScan(imageProxy: ImageProxy): Flow { + return callbackFlow { + val barcodeTask = barcodeScanner.process(inputImageProvider.provideImage(imageProxy)) + barcodeTask.addOnCompleteListener { + // We must call image.close() on received images when finished using them. + // Otherwise, new images may not be received or the camera may stall. + imageProxy.close() + } + barcodeTask.addOnSuccessListener { barcodeList -> + // The check for barcodeFound is done because the startScan method will be called + // continuously by the library as long as we are in the scanning screen. + // There will be a good chance that the same barcode gets identified multiple times and as a result + // success callback will be called multiple times. + if (!barcodeList.isNullOrEmpty() && !barcodeFound) { + barcodeFound = true + handleScanSuccess(barcodeList.firstOrNull()) + this@callbackFlow.close() + } + } + barcodeTask.addOnFailureListener { exception -> + this@callbackFlow.trySend( + CodeScannerStatus.Failure( + error = exception.message, + type = errorMapper.mapGoogleMLKitScanningErrors(exception) + ) + ) + this@callbackFlow.close() + } + + awaitClose() + } + } + + private fun ProducerScope.handleScanSuccess(code: Barcode?) { + code?.rawValue?.let { + trySend( + CodeScannerStatus.Success( + it, + barcodeFormatMapper.mapBarcodeFormat(code.format) + ) + ) + } ?: run { + trySend( + CodeScannerStatus.Failure( + error = "Failed to find a valid raw value!", + type = CodeScanningErrorType.Other(Throwable("Empty raw value")) + ) + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt new file mode 100644 index 000000000000..2184e4bcbe7e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.common.InputImage +import javax.inject.Inject + +interface InputImageProvider { + fun provideImage(imageProxy: ImageProxy): InputImage +} +class MediaImageProvider @Inject constructor() : InputImageProvider { + @androidx.camera.core.ExperimentalGetImage + override fun provideImage(imageProxy: ImageProxy): InputImage { + return InputImage.fromMediaImage(imageProxy.image!!, imageProxy.imageInfo.rotationDegrees) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt index d006711d1277..1b11cf2732da 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt @@ -13,15 +13,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentResultListener +import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment.Companion.KEY_BARCODE_SCANNING_REQUEST +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment.Companion.KEY_BARCODE_SCANNING_SCAN_STATUS +import org.wordpress.android.ui.barcodescanner.CodeScannerStatus import org.wordpress.android.ui.compose.components.VerticalScrollBox import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.ui.posts.BasicDialogViewModel @@ -49,6 +55,14 @@ class QRCodeAuthFragment : Fragment() { private val qrCodeAuthViewModel: QRCodeAuthViewModel by viewModels() private val dialogViewModel: BasicDialogViewModel by activityViewModels() + @Suppress("DEPRECATION") + private val resultListener = FragmentResultListener { requestKey, result -> + if (requestKey == KEY_BARCODE_SCANNING_REQUEST) { + val resultValue = result.getParcelable(KEY_BARCODE_SCANNING_SCAN_STATUS) + resultValue?.let { qrCodeAuthViewModel.handleScanningResult(it) } + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -64,6 +78,7 @@ class QRCodeAuthFragment : Fragment() { super.onViewCreated(view, savedInstanceState) initBackPressHandler() initViewModel(savedInstanceState) + initScannerResultListener() observeViewModel() } @@ -80,6 +95,14 @@ class QRCodeAuthFragment : Fragment() { qrCodeAuthViewModel.start(uri, isDeepLink, savedInstanceState) } + private fun initScannerResultListener() { + requireActivity().supportFragmentManager.setFragmentResultListener( + KEY_BARCODE_SCANNING_REQUEST, + viewLifecycleOwner, + resultListener + ) + } + private fun handleActionEvents(actionEvent: QRCodeAuthActionEvent) { when (actionEvent) { is LaunchDismissDialog -> launchDismissDialog(actionEvent.dialogModel) @@ -87,6 +110,7 @@ class QRCodeAuthFragment : Fragment() { is FinishActivity -> requireActivity().finish() } } + private fun launchDismissDialog(model: QRCodeAuthDialogModel) { dialogViewModel.showDialog( requireActivity().supportFragmentManager, @@ -96,17 +120,22 @@ class QRCodeAuthFragment : Fragment() { getString(model.message), getString(model.positiveButtonLabel), model.negativeButtonLabel?.let { label -> getString(label) }, - model.cancelButtonLabel?.let { label -> getString(label) } + model.cancelButtonLabel?.let { label -> getString(label) }, + false ) ) } private fun launchScanner() { qrCodeAuthViewModel.track(Stat.QRLOGIN_SCANNER_DISPLAYED) - val scanner = GmsBarcodeScanning.getClient(requireContext()) - scanner.startScan() - .addOnSuccessListener { barcode -> qrCodeAuthViewModel.onScanSuccess(barcode.rawValue) } - .addOnFailureListener { qrCodeAuthViewModel.onScanFailure() } + replaceFragment(BarcodeScanningFragment()) + } + + private fun replaceFragment(fragment: Fragment) { + val transaction: FragmentTransaction = requireActivity().supportFragmentManager.beginTransaction() + transaction.replace(R.id.fragment_container, fragment) + transaction.addToBackStack(null) + transaction.commit() } private fun initBackPressHandler() { @@ -114,6 +143,7 @@ class QRCodeAuthFragment : Fragment() { qrCodeAuthViewModel.onBackPressed() } } + override fun onSaveInstanceState(outState: Bundle) { qrCodeAuthViewModel.writeToBundle(outState) super.onSaveInstanceState(outState) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt index c17c5f267e16..e0a541dfbe77 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt @@ -23,6 +23,9 @@ import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthError import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthResult import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthValidateResult +import org.wordpress.android.ui.barcodescanner.BarcodeScanningTracker +import org.wordpress.android.ui.barcodescanner.CodeScannerStatus +import org.wordpress.android.ui.barcodescanner.ScanningSource import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction.Dismissed import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction.Negative @@ -52,12 +55,15 @@ class QRCodeAuthViewModel @Inject constructor( private val uiStateMapper: QRCodeAuthUiStateMapper, private val networkUtilsWrapper: NetworkUtilsWrapper, private val validator: QRCodeAuthValidator, - private val analyticsTrackerWrapper: AnalyticsTrackerWrapper + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, + private val barcodeScanningTracker: BarcodeScanningTracker ) : ViewModel() { private val _actionEvents = Channel(Channel.BUFFERED) val actionEvents = _actionEvents.receiveAsFlow() + private val _uiState = MutableStateFlow(Loading) val uiState: StateFlow = _uiState + private var trackingOrigin: String? = null private var data: String? = null private var token: String? = null @@ -65,6 +71,7 @@ class QRCodeAuthViewModel @Inject constructor( private var browser: String? = null private var lastState: QRCodeAuthUiStateType? = null private var isStarted = false + fun start(uri: String? = null, isDeepLink: Boolean = false, savedInstanceState: Bundle? = null) { if (isStarted) return isStarted = true @@ -100,7 +107,6 @@ class QRCodeAuthViewModel @Inject constructor( this::onAuthenticateCancelClicked ) ) - AUTHENTICATING -> postUiState(uiStateMapper.mapToAuthenticating(location = location, browser = browser)) DONE -> postUiState(uiStateMapper.mapToDone(this::onDismissClicked)) // errors @@ -111,25 +117,36 @@ class QRCodeAuthViewModel @Inject constructor( this::onCancelClicked ) ) - EXPIRED_TOKEN -> postUiState(uiStateMapper.mapToExpired(this::onScanAgainClicked, this::onCancelClicked)) NO_INTERNET -> { postUiState(uiStateMapper.mapToNoInternet(this::onScanAgainClicked, this::onCancelClicked)) } - else -> updateUiStateAndLaunchScanner() } } + fun handleScanningResult(status: CodeScannerStatus) { + when (status) { + is CodeScannerStatus.Success -> onScanSuccess(status.code) + is CodeScannerStatus.Failure -> onScanFailure(status) + is CodeScannerStatus.Exit -> onExit() + is CodeScannerStatus.NavigateUp -> onBackPressed() + } + } // https://apps.wordpress.com/get/?campaign=login-qr-code#qr-code-login?token=asdfadsfa&data=asdfasdf fun onScanSuccess(scannedValue: String?) { + barcodeScanningTracker.trackSuccess(ScanningSource.QRCODE_LOGIN) track(Stat.QRLOGIN_SCANNER_SCANNED_CODE) process(scannedValue) } - fun onScanFailure() { - // Note: This is a result of the tap on "X" within the scanner view - track(Stat.QRLOGIN_SCANNER_DISMISSED) + fun onScanFailure(status: CodeScannerStatus.Failure) { + barcodeScanningTracker.trackScanFailure(ScanningSource.QRCODE_LOGIN, status.type) + postActionEvent(FinishActivity) + } + + private fun onExit() { + track(Stat.QRLOGIN_SCANNER_DISMISSED_CAMERA_PERMISSION_DENIED) postActionEvent(FinishActivity) } @@ -282,8 +299,11 @@ class QRCodeAuthViewModel @Inject constructor( fun onDialogInteraction(interaction: DialogInteraction) { when (interaction) { - is Positive -> postActionEvent(FinishActivity) - is Negative -> Unit + is Positive -> { + track(Stat.QRLOGIN_SCANNER_DISMISSED) + postActionEvent(FinishActivity) + } + is Negative -> onScanAgainClicked() is Dismissed -> Unit } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 4fe2da0ac0f0..f9e398363788 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4807,14 +4807,14 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> - Scan Barcode - Camera permission is required to scan the barcode. - Grant Camera Permission - Camera permission is required in order to scan the barcode - You have permanently denied Camera permission. It is required in order to scan the barcode. Please enable it from the app settings - Grant - Cancel - Go to settings + Scan Barcode + Camera permission is required to scan the barcode. + Grant Camera Permission + Camera permission is required in order to scan the barcode + You have permanently denied Camera permission. It is required in order to scan the barcode. Please enable it from the app settings + Grant + Cancel + Go to settings Use a security key There was some trouble with the Security key login diff --git a/WordPress/src/test/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModelTest.kt index 7ef417a884f2..5018c590e58c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModelTest.kt @@ -28,6 +28,10 @@ import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthAuthenticateResult import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthResult import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthValidateResult +import org.wordpress.android.ui.barcodescanner.BarcodeScanningTracker +import org.wordpress.android.ui.barcodescanner.CodeScannerStatus +import org.wordpress.android.ui.barcodescanner.CodeScanningErrorType +import org.wordpress.android.ui.barcodescanner.ScanningSource import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction import org.wordpress.android.ui.qrcodeauth.QRCodeAuthUiState.Content.Done import org.wordpress.android.ui.qrcodeauth.QRCodeAuthUiState.Content.Validated @@ -80,6 +84,9 @@ class QRCodeAuthViewModelTest : BaseUnitTest() { @Mock lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + @Mock + lateinit var barcodeScanningTracker: BarcodeScanningTracker + private val uiStateMapper = QRCodeAuthUiStateMapper() private val validQueryParams = mapOf(DATA_KEY to DATA, TOKEN_KEY to TOKEN) @@ -89,6 +96,8 @@ class QRCodeAuthViewModelTest : BaseUnitTest() { private val errorTrackingMapAuthFailed = mutableMapOf("error" to "authentication_failed", "origin" to "menu") private val errorTrackingMapExpiredToken = mutableMapOf("error" to "expired_token", "origin" to "menu") + private val failureStatus = CodeScannerStatus.Failure("Failure", CodeScanningErrorType.Unknown) + @Before fun setUp() { viewModel = QRCodeAuthViewModel( @@ -96,7 +105,8 @@ class QRCodeAuthViewModelTest : BaseUnitTest() { uiStateMapper, networkUtilsWrapper, validator, - analyticsTrackerWrapper + analyticsTrackerWrapper, + barcodeScanningTracker ) whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) @@ -198,6 +208,8 @@ class QRCodeAuthViewModelTest : BaseUnitTest() { viewModel.start() viewModel.onScanSuccess(SCANNED_VALUE) + verify(barcodeScanningTracker).trackSuccess(ScanningSource.QRCODE_LOGIN) + verify(analyticsTrackerWrapper).track(eq(QRLOGIN_VERIFY_FAILED), eq(errorTrackingMapInvalidData)) } } @@ -510,12 +522,22 @@ class QRCodeAuthViewModelTest : BaseUnitTest() { fun `when scan fails, then finish activity event is raised`() { val actionEvents = mutableListOf() testWithData(actionEvents = actionEvents) { - viewModel.onScanFailure() + viewModel.onScanFailure(failureStatus) assertThat(actionEvents.last()).isInstanceOf(QRCodeAuthActionEvent.FinishActivity::class.java) } } + @Test + fun `when scan fails, then scan failed is tracked`() { + val actionEvents = mutableListOf() + testWithData(actionEvents = actionEvents) { + viewModel.onScanFailure(failureStatus) + + verify(barcodeScanningTracker).trackScanFailure(ScanningSource.QRCODE_LOGIN, failureStatus.type) + } + } + @Test fun `given valid scan, when no network connection, then no internet error is shown`() { val uiStates = mutableListOf() diff --git a/build.gradle b/build.gradle index 75052828cfdb..68f0424a0e10 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,7 @@ ext { androidxAnnotationVersion = '1.6.0' androidxAppcompatVersion = '1.6.1' androidxArchCoreVersion = '2.2.0' + androidxCameraVersion = '1.2.3' androidxComposeBomVersion = '2023.10.00' androidxComposeCompilerVersion = '1.5.3' androidxComposeNavigationVersion = '2.7.6' @@ -74,7 +75,9 @@ ext { googleGsonVersion = '2.10.1' googleMaterialVersion = '1.9.0' - googleMLKitBarcodeScanningVersion = '17.0.0' + googleMLKitBarcodeScanningVersion = '17.2.0' + googleMLKitBarcodeScanningCommonVersion = '17.0.0' + googleMLKitTextRecognitionVersion = '16.0.0' googlePlayReviewVersion = '2.0.1' googlePlayServicesAuthVersion = '20.4.1' googlePlayServicesCodeScannerVersion = '16.0.0-beta3'