diff --git a/identity/detekt-baseline.xml b/identity/detekt-baseline.xml index 3a021ca0a6d..cf155de747a 100644 --- a/identity/detekt-baseline.xml +++ b/identity/detekt-baseline.xml @@ -3,28 +3,28 @@ CyclomaticComplexMethod:CameraScreenLaunchedEffect.kt$@Composable internal fun CameraScreenLaunchedEffect( identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, verificationPage: VerificationPage, navController: NavController, cameraManager: IdentityCameraManager, onCameraReady: () -> Unit ) - CyclomaticComplexMethod:DocumenetScanScreen.kt$@Composable internal fun DocumentScanScreen( navController: NavController, identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, frontScanType: IdentityScanState.ScanType, backScanType: IdentityScanState.ScanType?, shouldStartFromBack: Boolean, messageRes: DocumentScanMessageRes, collectedDataParamType: CollectedDataParam.Type, route: String ) CyclomaticComplexMethod:IdentityTopLevelDestination.kt$internal fun String.routeToScreenName(): String - CyclomaticComplexMethod:IdentityViewModel.kt$IdentityViewModel$fun updateNewScanType(scanType: IdentityScanState.ScanType) CyclomaticComplexMethod:OTPScreen.kt$@Composable internal fun OTPScreen( navController: NavController, identityViewModel: IdentityViewModel, otpViewModelFactory: ViewModelProvider.Factory = OTPViewModel.Factory( identityRepository = identityViewModel.identityRepository, verificationArgs = identityViewModel.verificationArgs ) ) - CyclomaticComplexMethod:UploadScreen.kt$@Composable internal fun UploadScreen( navController: NavController, identityViewModel: IdentityViewModel, collectedDataParamType: CollectedDataParam.Type, route: String, @StringRes titleRes: Int, @StringRes contextRes: Int, frontInfo: DocumentUploadSideInfo, backInfo: DocumentUploadSideInfo? ) + CyclomaticComplexMethod:UploadScreen.kt$@Composable internal fun UploadScreen( navController: NavController, identityViewModel: IdentityViewModel, ) LargeClass:IdentityViewModel.kt$IdentityViewModel : AndroidViewModel LargeClass:IdentityViewModelTest.kt$IdentityViewModelTest LongMethod:AddressSection.kt$@Composable internal fun AddressSection( enabled: Boolean, identityViewModel: IdentityViewModel, addressCountries: List<Country>, addressNotListedText: String, navController: NavController, onAddressCollected: (Resource<RequiredInternationalAddress>) -> Unit ) LongMethod:CameraScreenLaunchedEffect.kt$@Composable internal fun CameraScreenLaunchedEffect( identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, verificationPage: VerificationPage, navController: NavController, cameraManager: IdentityCameraManager, onCameraReady: () -> Unit ) + LongMethod:CameraScreenLaunchedEffectLight.kt$@Composable internal fun CameraScreenLaunchedEffectLight( identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, verificationPage: VerificationPage, navController: NavController ) LongMethod:ConfirmationScreen.kt$@Composable internal fun ConfirmationScreen( navController: NavController, identityViewModel: IdentityViewModel, verificationFlowFinishable: VerificationFlowFinishable ) LongMethod:ConsentScreen.kt$@Composable private fun SuccessUI( merchantLogoUri: Uri, consentPage: VerificationPageStaticContentConsentPage, bottomSheets: Map<String, VerificationPageStaticContentBottomSheetContent>?, visitedIndividualWelcomePage: Boolean, onConsentAgreed: () -> Unit, onConsentDeclined: () -> Unit ) LongMethod:ConsentWelcomeHeader.kt$@Composable internal fun ConsentWelcomeHeader( modifier: Modifier = Modifier, merchantLogoUri: Uri, title: String?, showLogos: Boolean = true ) LongMethod:DebugScreen.kt$@Composable internal fun CompleteWithTestDataSection( onClickSubmit: (CompleteOption) -> Unit ) LongMethod:DebugScreen.kt$@Composable internal fun DebugScreen( navController: NavController, identityViewModel: IdentityViewModel, verificationFlowFinishable: VerificationFlowFinishable ) LongMethod:DocSelectionScreen.kt$@Composable internal fun DocSelectionScreen( navController: NavController, identityViewModel: IdentityViewModel, cameraPermissionEnsureable: CameraPermissionEnsureable ) - LongMethod:DocumenetScanScreen.kt$@Composable internal fun DocumentScanScreen( navController: NavController, identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, frontScanType: IdentityScanState.ScanType, backScanType: IdentityScanState.ScanType?, shouldStartFromBack: Boolean, messageRes: DocumentScanMessageRes, collectedDataParamType: CollectedDataParam.Type, route: String ) + LongMethod:DocumentScanScreen.kt$@Composable internal fun DocumentScanScreen( navController: NavController, identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel ) + LongMethod:DocumentScanScreen.kt$@Composable private fun ScanScreen( newDisplayState: IdentityScanState?, documentScannerState: IdentityScanViewModel.State, targetScanType: IdentityScanState.ScanType?, identityScanViewModel: IdentityScanViewModel, identityViewModel: IdentityViewModel, lifecycleOwner: LifecycleOwner, cameraManager: IdentityCameraManager, onContinueClick: () -> Unit ) LongMethod:ErrorScreen.kt$@Composable internal fun ErrorScreen( identityViewModel: IdentityViewModel, title: String, modifier: Modifier = Modifier, message1: String? = null, message2: String? = null, topButton: ErrorScreenButton? = null, bottomButton: ErrorScreenButton? = null, ) LongMethod:IDDetectorAnalyzer.kt$IDDetectorAnalyzer$override suspend fun analyze( data: AnalyzerInput, state: IdentityScanState ): AnalyzerOutput LongMethod:IDNumberSection.kt$@Composable internal fun IDNumberSection( enabled: Boolean, idNumberCountries: List<Country>, countryNotListedText: String, navController: NavController, onIdNumberCollected: (Resource<IdNumberParam>) -> Unit ) LongMethod:IdentityActivity.kt$IdentityActivity$@ExperimentalMaterialApi override fun onCreate(savedInstanceState: Bundle?) LongMethod:IdentityNavGraph.kt$@Composable @ExperimentalMaterialApi internal fun IdentityNavGraph( navController: NavHostController = rememberNavController(), identityViewModel: IdentityViewModel, fallbackUrlLauncher: FallbackUrlLauncher, appSettingsOpenable: AppSettingsOpenable, cameraPermissionEnsureable: CameraPermissionEnsureable, verificationFlowFinishable: VerificationFlowFinishable, identityScanViewModelFactory: IdentityScanViewModel.IdentityScanViewModelFactory, onTopBarNavigationClick: () -> Unit, topBarState: IdentityTopBarState, onNavControllerCreated: (NavController) -> Unit ) - LongMethod:IdentityViewModel.kt$IdentityViewModel$internal fun uploadScanResult( result: IdentityAggregator.FinalResult, verificationPage: VerificationPage, targetScanType: IdentityScanState.ScanType? ) + LongMethod:IdentityViewModel.kt$IdentityViewModel$internal fun uploadScanResult( result: IdentityAggregator.FinalResult, verificationPage: VerificationPage ) LongMethod:IdentityViewModel.kt$IdentityViewModel$private fun uploadDocumentImagesAndNotify( imageFile: File, filePurpose: StripeFilePurpose, uploadMethod: UploadMethod, scores: List<Float>? = null, isHighRes: Boolean, isFront: Boolean, scanType: IdentityScanState.ScanType, compressionQuality: Float ) LongMethod:IdentityViewModel.kt$IdentityViewModel$suspend fun postVerificationPageDataForDocSelection( type: CollectedDataParam.Type, navController: NavController, viewLifecycleOwner: LifecycleOwner, cameraPermissionEnsureable: CameraPermissionEnsureable ) LongMethod:IdentityViewModelTest.kt$IdentityViewModelTest$private fun testUploadDocumentScanSuccessResult(isFront: Boolean) @@ -35,7 +35,7 @@ LongMethod:OTPScreen.kt$@Composable private fun OTPViewStateEffect( viewState: OTPViewState?, navController: NavController, identityViewModel: IdentityViewModel, viewModel: OTPViewModel, focusRequester: FocusRequester ) LongMethod:SelfieScreen.kt$@Composable internal fun SelfieScanScreen( navController: NavController, identityViewModel: IdentityViewModel, identityScanViewModel: IdentityScanViewModel, ) LongMethod:SelfieScreen.kt$@Composable private fun ResultView( displayState: IdentityScanState, allowImageCollectionHtml: String, isSubmittingSelfie: Boolean, allowImageCollection: Boolean, navController: NavController, onAllowImageCollectionChanged: (Boolean) -> Unit ) - LongMethod:UploadScreen.kt$@Composable internal fun UploadScreen( navController: NavController, identityViewModel: IdentityViewModel, collectedDataParamType: CollectedDataParam.Type, route: String, @StringRes titleRes: Int, @StringRes contextRes: Int, frontInfo: DocumentUploadSideInfo, backInfo: DocumentUploadSideInfo? ) + LongMethod:UploadScreen.kt$@Composable internal fun UploadScreen( navController: NavController, identityViewModel: IdentityViewModel, ) MagicNumber:DefaultIdentityIO.kt$DefaultIdentityIO$100 MagicNumber:DefaultIdentityIO.kt$DefaultIdentityIO$5 MagicNumber:DobParam.kt$DobParam.Companion$4 diff --git a/identity/src/main/java/com/stripe/android/identity/analytics/IdentityAnalyticsRequestFactory.kt b/identity/src/main/java/com/stripe/android/identity/analytics/IdentityAnalyticsRequestFactory.kt index 71a3e9b7b1e..e2355d43297 100644 --- a/identity/src/main/java/com/stripe/android/identity/analytics/IdentityAnalyticsRequestFactory.kt +++ b/identity/src/main/java/com/stripe/android/identity/analytics/IdentityAnalyticsRequestFactory.kt @@ -156,13 +156,8 @@ internal class IdentityAnalyticsRequestFactory @Inject constructor( ) ) - fun cameraPermissionGranted( - scanType: IdentityScanState.ScanType - ) = requestFactory.createRequest( - eventName = EVENT_CAMERA_PERMISSION_GRANTED, - additionalParams = additionalParamWithEventMetadata( - PARAM_SCAN_TYPE to scanType.toParam() - ) + fun cameraPermissionGranted() = requestFactory.createRequest( + eventName = EVENT_CAMERA_PERMISSION_GRANTED ) fun documentTimeout( @@ -247,21 +242,15 @@ internal class IdentityAnalyticsRequestFactory @Inject constructor( private fun IdentityScanState.ScanType.toParam(): String = when (this) { - IdentityScanState.ScanType.ID_FRONT -> ID - IdentityScanState.ScanType.ID_BACK -> ID - IdentityScanState.ScanType.PASSPORT -> PASSPORT - IdentityScanState.ScanType.DL_FRONT -> DRIVER_LICENSE - IdentityScanState.ScanType.DL_BACK -> DRIVER_LICENSE + IdentityScanState.ScanType.DOC_FRONT -> DOC_FRONT + IdentityScanState.ScanType.DOC_BACK -> DOC_BACK IdentityScanState.ScanType.SELFIE -> SELFIE } private fun IdentityScanState.ScanType.toSide(): String = when (this) { - IdentityScanState.ScanType.ID_FRONT -> FRONT - IdentityScanState.ScanType.ID_BACK -> BACK - IdentityScanState.ScanType.PASSPORT -> FRONT - IdentityScanState.ScanType.DL_FRONT -> FRONT - IdentityScanState.ScanType.DL_BACK -> BACK + IdentityScanState.ScanType.DOC_FRONT -> FRONT + IdentityScanState.ScanType.DOC_BACK -> BACK else -> { throw IllegalArgumentException("Unknown type: $this") } @@ -271,8 +260,8 @@ internal class IdentityAnalyticsRequestFactory @Inject constructor( const val CLIENT_ID = "mobile-identity-sdk" const val ORIGIN = "stripe-identity-android" const val ID = "id" - const val PASSPORT = "passport" - const val DRIVER_LICENSE = "driver_license" + const val DOC_FRONT = "doc_front" + const val DOC_BACK = "doc_front" const val SELFIE = "selfie" const val FRONT = "front" const val BACK = "back" @@ -334,14 +323,10 @@ internal class IdentityAnalyticsRequestFactory @Inject constructor( const val SCREEN_NAME_CONSENT = "consent" const val SCREEN_NAME_DOC_SELECT = "document_select" - const val SCREEN_NAME_LIVE_CAPTURE_PASSPORT = "live_capture_passport" - const val SCREEN_NAME_LIVE_CAPTURE_ID = "live_capture_id" - const val SCREEN_NAME_LIVE_CAPTURE_DRIVER_LICENSE = "live_capture_driver_license" - const val SCREEN_NAME_FILE_UPLOAD_PASSPORT = "file_upload_passport" - const val SCREEN_NAME_FILE_UPLOAD_ID = "file_upload_id" - const val SCREEN_NAME_FILE_UPLOAD_DRIVER_LICENSE = "file_upload_driver_license" const val SCREEN_NAME_SELFIE_WARMUP = "selfie_warmup" const val SCREEN_NAME_SELFIE = "selfie" + const val SCREEN_NAME_LIVE_CAPTURE = "live_capture" + const val SCREEN_NAME_FILE_UPLOAD = "file_upload" const val SCREEN_NAME_CONFIRMATION = "confirmation" const val SCREEN_NAME_ERROR = "error" const val SCREEN_NAME_INDIVIDUAL = "individual" diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/CouldNotCaptureDestination.kt b/identity/src/main/java/com/stripe/android/identity/navigation/CouldNotCaptureDestination.kt index 5d0440a91f2..866c32645c1 100644 --- a/identity/src/main/java/com/stripe/android/identity/navigation/CouldNotCaptureDestination.kt +++ b/identity/src/main/java/com/stripe/android/identity/navigation/CouldNotCaptureDestination.kt @@ -3,42 +3,30 @@ package com.stripe.android.identity.navigation import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.navArgument -import com.stripe.android.identity.states.IdentityScanState internal class CouldNotCaptureDestination( - scanType: IdentityScanState.ScanType, - requireLiveCapture: Boolean + fromSelfie: Boolean ) : IdentityTopLevelDestination() { override val destinationRoute = ROUTE override val routeWithArgs = destinationRoute.withParams( - ARG_COULD_NOT_CAPTURE_SCAN_TYPE to scanType, - ARG_REQUIRE_LIVE_CAPTURE to requireLiveCapture + ARG_FROM_SELFIE to fromSelfie ) companion object { const val COULD_NOT_CAPTURE = "CouldNotCapture" - const val ARG_COULD_NOT_CAPTURE_SCAN_TYPE = "scanType" - const val ARG_REQUIRE_LIVE_CAPTURE = "requireLiveCapture" + const val ARG_FROM_SELFIE = "fromSelfie" val ROUTE = object : DestinationRoute() { override val routeBase = COULD_NOT_CAPTURE override val arguments = listOf( - navArgument(ARG_COULD_NOT_CAPTURE_SCAN_TYPE) { - type = NavType.EnumType(IdentityScanState.ScanType::class.java) - }, - navArgument(ARG_REQUIRE_LIVE_CAPTURE) { + navArgument(ARG_FROM_SELFIE) { type = NavType.BoolType } ) } - fun couldNotCaptureScanType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable( - ARG_COULD_NOT_CAPTURE_SCAN_TYPE - ) as IdentityScanState.ScanType - - fun requireLiveCapture(backStackEntry: NavBackStackEntry) = - backStackEntry.getBooleanArgument(ARG_REQUIRE_LIVE_CAPTURE) + fun fromSelfie(backStackEntry: NavBackStackEntry) = + backStackEntry.getBooleanArgument(ARG_FROM_SELFIE) } } diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/DocumentUploadDestination.kt b/identity/src/main/java/com/stripe/android/identity/navigation/DocumentUploadDestination.kt new file mode 100644 index 00000000000..3e4e2488a88 --- /dev/null +++ b/identity/src/main/java/com/stripe/android/identity/navigation/DocumentUploadDestination.kt @@ -0,0 +1,14 @@ +package com.stripe.android.identity.navigation + +internal object DocumentUploadDestination : IdentityTopLevelDestination( + popUpToParam = PopUpToParam( + route = DocSelectionDestination.ROUTE.route, + inclusive = false + ) +) { + val ROUTE = object : DestinationRoute() { + override val routeBase = UPLOAD + } + + override val destinationRoute = ROUTE +} diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/IdentityNavGraph.kt b/identity/src/main/java/com/stripe/android/identity/navigation/IdentityNavGraph.kt index dfc7bb2195e..9465de2cbc3 100644 --- a/identity/src/main/java/com/stripe/android/identity/navigation/IdentityNavGraph.kt +++ b/identity/src/main/java/com/stripe/android/identity/navigation/IdentityNavGraph.kt @@ -35,19 +35,13 @@ import com.stripe.android.identity.VerificationFlowFinishable import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory import com.stripe.android.identity.networking.models.CollectedDataParam import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.getDisplayName -import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.toUploadDestination -import com.stripe.android.identity.states.IdentityScanState -import com.stripe.android.identity.states.IdentityScanState.Companion.toScanDestination -import com.stripe.android.identity.states.IdentityScanState.Companion.toUploadDestination import com.stripe.android.identity.ui.BottomSheet import com.stripe.android.identity.ui.ConfirmationScreen import com.stripe.android.identity.ui.ConsentScreen import com.stripe.android.identity.ui.CountryNotListedScreen import com.stripe.android.identity.ui.DebugScreen import com.stripe.android.identity.ui.DocSelectionScreen -import com.stripe.android.identity.ui.DocumentScanMessageRes import com.stripe.android.identity.ui.DocumentScanScreen -import com.stripe.android.identity.ui.DocumentUploadSideInfo import com.stripe.android.identity.ui.ErrorScreen import com.stripe.android.identity.ui.ErrorScreenButton import com.stripe.android.identity.ui.IdentityTopAppBar @@ -128,50 +122,18 @@ internal fun IdentityNavGraph( cameraPermissionEnsureable = cameraPermissionEnsureable ) } - screen(IDScanDestination.ROUTE) { + screen(DocumentScanDestination.ROUTE) { val identityScanViewModel: IdentityScanViewModel = viewModel(factory = identityScanViewModelFactory) ScanDestinationEffect( lifecycleOwner = it, identityScanViewModel = identityScanViewModel ) - DocumentScanScreenContent( + DocumentScanScreen( navController = navController, identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - backStackEntry = it, - route = IDScanDestination.ROUTE.route - ) - } - screen(DriverLicenseScanDestination.ROUTE) { - val identityScanViewModel: IdentityScanViewModel = - viewModel(factory = identityScanViewModelFactory) - ScanDestinationEffect( - lifecycleOwner = it, identityScanViewModel = identityScanViewModel ) - DocumentScanScreenContent( - navController = navController, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - backStackEntry = it, - route = DriverLicenseScanDestination.ROUTE.route - ) - } - screen(PassportScanDestination.ROUTE) { - val identityScanViewModel: IdentityScanViewModel = - viewModel(factory = identityScanViewModelFactory) - ScanDestinationEffect( - lifecycleOwner = it, - identityScanViewModel = identityScanViewModel - ) - DocumentScanScreenContent( - navController = navController, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - backStackEntry = it, - route = PassportScanDestination.ROUTE.route - ) } screen(SelfieWarmupDestination.ROUTE) { SelfieWarmupScreen( @@ -192,47 +154,10 @@ internal fun IdentityNavGraph( identityScanViewModel = identityScanViewModel ) } - screen(IDUploadDestination.ROUTE) { - LaunchedEffect(Unit) { - identityViewModel.updateImageHandlerScanTypes( - IdentityScanState.ScanType.ID_FRONT, - IdentityScanState.ScanType.ID_BACK - ) - } - DocumentUploadScreenContent( - navController = navController, - identityViewModel = identityViewModel, - backStackEntry = it, - route = IDUploadDestination.ROUTE.route - ) - } - screen(DriverLicenseUploadDestination.ROUTE) { - LaunchedEffect(Unit) { - identityViewModel.updateImageHandlerScanTypes( - IdentityScanState.ScanType.DL_FRONT, - IdentityScanState.ScanType.DL_BACK - ) - } - DocumentUploadScreenContent( + screen(DocumentUploadDestination.ROUTE) { + UploadScreen( navController = navController, identityViewModel = identityViewModel, - backStackEntry = it, - route = DriverLicenseUploadDestination.ROUTE.route - ) - } - screen(PassportUploadDestination.ROUTE) { - LaunchedEffect(Unit) { - identityViewModel.updateImageHandlerScanTypes( - IdentityScanState.ScanType.PASSPORT, - null - ) - } - DocumentUploadScreenContent( - navController = navController, - identityViewModel = identityViewModel, - backStackEntry = it, - route = PassportUploadDestination.ROUTE.route, - hasBack = false ) } screen(IndividualDestination.ROUTE) { @@ -284,7 +209,7 @@ internal fun IdentityNavGraph( IdentityAnalyticsRequestFactory.SCREEN_NAME_ERROR ) navController.navigateTo( - collectedDataParamType.toUploadDestination() + DocumentUploadDestination ) } } else { @@ -302,20 +227,19 @@ internal fun IdentityNavGraph( ) } screen(CouldNotCaptureDestination.ROUTE) { - val scanType = CouldNotCaptureDestination.couldNotCaptureScanType(it) - val requireLiveCapture = CouldNotCaptureDestination.requireLiveCapture(it) + val fromSelfie = CouldNotCaptureDestination.fromSelfie(it) ErrorScreen( identityViewModel = identityViewModel, title = stringResource(id = R.string.stripe_could_not_capture_title), message1 = stringResource(id = R.string.stripe_could_not_capture_body1), - message2 = if (scanType == IdentityScanState.ScanType.SELFIE) { + message2 = if (fromSelfie) { null } else { stringResource( R.string.stripe_could_not_capture_body2 ) }, - topButton = if (scanType == IdentityScanState.ScanType.SELFIE) { + topButton = if (fromSelfie) { null } else { ErrorScreenButton( @@ -325,7 +249,7 @@ internal fun IdentityNavGraph( IdentityAnalyticsRequestFactory.SCREEN_NAME_ERROR ) navController.navigateTo( - scanType.toUploadDestination() + DocumentUploadDestination ) } }, @@ -337,7 +261,11 @@ internal fun IdentityNavGraph( IdentityAnalyticsRequestFactory.SCREEN_NAME_ERROR ) navController.navigateTo( - scanType.toScanDestination() + if (fromSelfie) { + SelfieDestination + } else { + DocumentScanDestination + } ) } ) @@ -399,76 +327,6 @@ internal fun IdentityNavGraph( } } -@Composable -private fun DocumentScanScreenContent( - navController: NavController, - identityViewModel: IdentityViewModel, - identityScanViewModel: IdentityScanViewModel, - backStackEntry: NavBackStackEntry, - route: String -) { - DocumentScanScreen( - navController = navController, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - frontScanType = DocumentScanDestination.frontScanType(backStackEntry), - backScanType = DocumentScanDestination.backScanType(backStackEntry), - shouldStartFromBack = DocumentScanDestination.shouldStartFromBack(backStackEntry), - messageRes = DocumentScanMessageRes( - DocumentScanDestination.frontTitleStringRes(backStackEntry), - DocumentScanDestination.backTitleStringRes(backStackEntry), - DocumentScanDestination.frontMessageStringRes(backStackEntry), - DocumentScanDestination.backMessageStringRes(backStackEntry), - ), - collectedDataParamType = DocumentScanDestination.collectedDataParamType(backStackEntry), - route = route, - ) -} - -@Composable -private fun DocumentUploadScreenContent( - navController: NavController, - identityViewModel: IdentityViewModel, - backStackEntry: NavBackStackEntry, - route: String, - hasBack: Boolean = true -) { - UploadScreen( - navController = navController, - identityViewModel = identityViewModel, - collectedDataParamType = DocumentUploadDestination.collectedDataParamType(backStackEntry), - route = route, - titleRes = DocumentUploadDestination.titleRes(backStackEntry), - contextRes = DocumentUploadDestination.contextRes(backStackEntry), - frontInfo = DocumentUploadSideInfo( - descriptionRes = DocumentUploadDestination.frontDescriptionRes( - backStackEntry - ), - checkmarkContentDescriptionRes = - DocumentUploadDestination.frontCheckMarkDescriptionRes( - backStackEntry - ), - scanType = DocumentUploadDestination.frontScanType(backStackEntry) - ), - backInfo = - if (hasBack) { - DocumentUploadSideInfo( - descriptionRes = - DocumentUploadDestination.backDescriptionRes( - backStackEntry - ), - checkmarkContentDescriptionRes = - DocumentUploadDestination.backCheckMarkDescriptionRes( - backStackEntry - ), - scanType = DocumentUploadDestination.backScanType(backStackEntry) - ) - } else { - null - } - ) -} - @ExperimentalMaterialApi /** * Built a composable screen with ModalBottomSheetLayout diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/IdentityTopLevelDestination.kt b/identity/src/main/java/com/stripe/android/identity/navigation/IdentityTopLevelDestination.kt index 05f360ff08a..d78f46c0ffb 100644 --- a/identity/src/main/java/com/stripe/android/identity/navigation/IdentityTopLevelDestination.kt +++ b/identity/src/main/java/com/stripe/android/identity/navigation/IdentityTopLevelDestination.kt @@ -91,18 +91,10 @@ internal fun String.routeToScreenName(): String = when (this) { IdentityAnalyticsRequestFactory.SCREEN_NAME_CONSENT DocSelectionDestination.ROUTE.route -> IdentityAnalyticsRequestFactory.SCREEN_NAME_DOC_SELECT - IDScanDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE_ID - PassportScanDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE_PASSPORT - DriverLicenseScanDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE_DRIVER_LICENSE - IDUploadDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_FILE_UPLOAD_ID - PassportUploadDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_FILE_UPLOAD_PASSPORT - DriverLicenseUploadDestination.ROUTE.route -> - IdentityAnalyticsRequestFactory.SCREEN_NAME_FILE_UPLOAD_DRIVER_LICENSE + DocumentScanDestination.ROUTE.route -> + IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE + DocumentUploadDestination.ROUTE.route -> + IdentityAnalyticsRequestFactory.SCREEN_NAME_FILE_UPLOAD SelfieDestination.ROUTE.route -> IdentityAnalyticsRequestFactory.SCREEN_NAME_SELFIE ConfirmationDestination.ROUTE.route -> @@ -132,17 +124,9 @@ internal fun String.routeToRequirement(): List = when (this) { listOf(Requirement.BIOMETRICCONSENT) DocSelectionDestination.ROUTE.route -> listOf(Requirement.IDDOCUMENTTYPE) - IDUploadDestination.ROUTE.route -> + DocumentScanDestination.ROUTE.route -> listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) - PassportUploadDestination.ROUTE.route -> - listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) - DriverLicenseUploadDestination.ROUTE.route -> - listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) - IDScanDestination.ROUTE.route -> - listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) - PassportScanDestination.ROUTE.route -> - listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) - DriverLicenseScanDestination.ROUTE.route -> + DocumentUploadDestination.ROUTE.route -> listOf(Requirement.IDDOCUMENTFRONT, Requirement.IDDOCUMENTBACK) SelfieDestination.ROUTE.route -> listOf(Requirement.FACE) diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/NavControllerExt.kt b/identity/src/main/java/com/stripe/android/identity/navigation/NavControllerExt.kt index a4b38875eac..8a393846da3 100644 --- a/identity/src/main/java/com/stripe/android/identity/navigation/NavControllerExt.kt +++ b/identity/src/main/java/com/stripe/android/identity/navigation/NavControllerExt.kt @@ -94,12 +94,8 @@ internal fun NavController.navigateToFinalErrorScreen( * Route of all screens that collect front/back of a document. */ private val DOCUMENT_UPLOAD_ROUTES = setOf( - IDUploadDestination.ROUTE.route, - DriverLicenseUploadDestination.ROUTE.route, - PassportUploadDestination.ROUTE.route, - IDScanDestination.ROUTE.route, - DriverLicenseScanDestination.ROUTE.route, - PassportScanDestination.ROUTE.route + DocumentScanDestination.ROUTE.route, + DocumentUploadDestination.ROUTE.route ) /** @@ -141,7 +137,9 @@ internal suspend fun NavController.navigateOnVerificationPageData( onMissingOtp() } else if (verificationPageData.isMissingConsent()) { navigateTo(ConsentDestination) - } else if (verificationPageData.isMissingDocType()) { + } + // TODO - remove this when backend removes docType + else if (verificationPageData.isMissingDocType()) { navigateTo(DocSelectionDestination) } else if (verificationPageData.isMissingFront()) { onMissingFront() diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/ScanDestinations.kt b/identity/src/main/java/com/stripe/android/identity/navigation/ScanDestinations.kt index 1983de9257a..605862cc2a9 100644 --- a/identity/src/main/java/com/stripe/android/identity/navigation/ScanDestinations.kt +++ b/identity/src/main/java/com/stripe/android/identity/navigation/ScanDestinations.kt @@ -1,173 +1,10 @@ package com.stripe.android.identity.navigation -import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.lifecycle.LifecycleOwner -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavType -import androidx.navigation.navArgument -import com.stripe.android.identity.R -import com.stripe.android.identity.networking.models.CollectedDataParam -import com.stripe.android.identity.states.IdentityScanState import com.stripe.android.identity.viewmodel.IdentityScanViewModel -internal abstract class DocumentScanDestination( - shouldStartFromBack: Boolean = false, - shouldPopUpToDocSelection: Boolean = false, - frontScanType: IdentityScanState.ScanType, - backScanType: IdentityScanState.ScanType, - @StringRes frontTitleStringRes: Int, - @StringRes backTitleStringRes: Int = INVALID_STRING_RES, - @StringRes frontMessageStringRes: Int, - @StringRes backMessageStringRes: Int = INVALID_STRING_RES, - collectedDataParamType: CollectedDataParam.Type -) : IdentityTopLevelDestination( - popUpToParam = if (shouldPopUpToDocSelection) { - PopUpToParam( - route = DocSelectionDestination.ROUTE.route, - inclusive = false - ) - } else { - null - } -) { - override val routeWithArgs by lazy { - destinationRoute.withParams( - ARG_SHOULD_START_FROM_BACK to shouldStartFromBack, - ARG_FRONT_SCAN_TYPE to frontScanType, - ARG_BACK_SCAN_TYPE to backScanType, - ARG_FRONT_TITLE_STRING_RES to frontTitleStringRes, - ARG_BACK_TITLE_STRING_RES to backTitleStringRes, - ARG_FRONT_MESSAGE_STRING_RES to frontMessageStringRes, - ARG_BACK_MESSAGE_STRING_RES to backMessageStringRes, - ARG_COLLECTED_DATA_PARAM_TYPE to collectedDataParamType - ) - } - - internal companion object { - fun shouldStartFromBack(backStackEntry: NavBackStackEntry) = - backStackEntry.getBooleanArgument(ARG_SHOULD_START_FROM_BACK) - - fun frontScanType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_FRONT_SCAN_TYPE) as IdentityScanState.ScanType - - fun backScanType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_BACK_SCAN_TYPE) as? IdentityScanState.ScanType - - fun frontTitleStringRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_FRONT_TITLE_STRING_RES) - - fun backTitleStringRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_BACK_TITLE_STRING_RES) - - fun frontMessageStringRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_FRONT_MESSAGE_STRING_RES) - - fun backMessageStringRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_BACK_MESSAGE_STRING_RES) - - fun collectedDataParamType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_COLLECTED_DATA_PARAM_TYPE) as CollectedDataParam.Type - } -} - -internal class DocumentScanDestinationRoute( - override val routeBase: String -) : - IdentityTopLevelDestination.DestinationRoute() { - override val arguments = mutableListOf( - navArgument(ARG_SHOULD_START_FROM_BACK) { - type = NavType.BoolType - }, - navArgument(ARG_FRONT_SCAN_TYPE) { - type = NavType.EnumType(IdentityScanState.ScanType::class.java) - }, - navArgument(ARG_BACK_SCAN_TYPE) { - type = NavType.EnumType(IdentityScanState.ScanType::class.java) - }, - navArgument(ARG_FRONT_TITLE_STRING_RES) { - type = NavType.IntType - }, - navArgument(ARG_BACK_TITLE_STRING_RES) { - type = NavType.IntType - }, - navArgument(ARG_FRONT_MESSAGE_STRING_RES) { - type = NavType.IntType - }, - navArgument(ARG_BACK_MESSAGE_STRING_RES) { - type = NavType.IntType - }, - navArgument(ARG_COLLECTED_DATA_PARAM_TYPE) { - type = NavType.EnumType(CollectedDataParam.Type::class.java) - } - ) -} - -internal class PassportScanDestination( - shouldStartFromBack: Boolean = false, - shouldPopUpToDocSelection: Boolean = false -) : DocumentScanDestination( - shouldStartFromBack = shouldStartFromBack, - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.PASSPORT, - backScanType = IdentityScanState.ScanType.PASSPORT, - frontTitleStringRes = R.string.stripe_passport, - frontMessageStringRes = R.string.stripe_position_passport, - collectedDataParamType = CollectedDataParam.Type.PASSPORT -) { - override val destinationRoute = ROUTE - - companion object { - private const val PASSPORT_SCAN = "PassportScan" - val ROUTE = DocumentScanDestinationRoute(routeBase = PASSPORT_SCAN) - } -} - -internal class IDScanDestination( - shouldStartFromBack: Boolean = false, - shouldPopUpToDocSelection: Boolean = false -) : DocumentScanDestination( - shouldStartFromBack = shouldStartFromBack, - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.ID_FRONT, - backScanType = IdentityScanState.ScanType.ID_BACK, - frontTitleStringRes = R.string.stripe_front_of_id, - backTitleStringRes = R.string.stripe_back_of_id, - frontMessageStringRes = R.string.stripe_position_id_front, - backMessageStringRes = R.string.stripe_position_id_back, - collectedDataParamType = CollectedDataParam.Type.IDCARD -) { - override val destinationRoute = ROUTE - - companion object { - private const val ID_SCAN = "IDScan" - val ROUTE = DocumentScanDestinationRoute(routeBase = ID_SCAN) - } -} - -internal class DriverLicenseScanDestination( - shouldStartFromBack: Boolean = false, - shouldPopUpToDocSelection: Boolean = false, -) : DocumentScanDestination( - shouldStartFromBack = shouldStartFromBack, - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.DL_FRONT, - backScanType = IdentityScanState.ScanType.DL_BACK, - frontTitleStringRes = R.string.stripe_front_of_dl, - backTitleStringRes = R.string.stripe_back_of_dl, - frontMessageStringRes = R.string.stripe_position_dl_front, - backMessageStringRes = R.string.stripe_position_dl_back, - collectedDataParamType = CollectedDataParam.Type.DRIVINGLICENSE -) { - override val destinationRoute = ROUTE - - companion object { - private const val DRIVE_LICENSE_SCAN = "DriverLicenseScan" - val ROUTE = DocumentScanDestinationRoute(routeBase = DRIVE_LICENSE_SCAN) - } -} - @Composable internal fun ScanDestinationEffect( lifecycleOwner: LifecycleOwner, @@ -194,13 +31,19 @@ internal object SelfieDestination : IdentityTopLevelDestination( override val destinationRoute = ROUTE } +internal object DocumentScanDestination : IdentityTopLevelDestination( + popUpToParam = PopUpToParam( + route = DocSelectionDestination.ROUTE.route, + inclusive = false + ) +) { + val ROUTE = object : DestinationRoute() { + override val routeBase = SCAN + } + + override val destinationRoute = ROUTE +} + internal const val SELFIE = "Selfie" -internal const val ARG_SHOULD_START_FROM_BACK = "startFromBack" -internal const val ARG_FRONT_SCAN_TYPE = "frontScanType" -internal const val ARG_BACK_SCAN_TYPE = "backScanType" -internal const val ARG_FRONT_TITLE_STRING_RES = "frontTitleStringRes" -internal const val ARG_BACK_TITLE_STRING_RES = "backTitleStringRes" -internal const val ARG_FRONT_MESSAGE_STRING_RES = "frontMessageStringRes" -internal const val ARG_BACK_MESSAGE_STRING_RES = "backMessageStringRes" -internal const val ARG_COLLECTED_DATA_PARAM_TYPE = "collectedDataParamType" -internal const val INVALID_STRING_RES = -1 +internal const val SCAN = "Scan" +internal const val UPLOAD = "Upload" diff --git a/identity/src/main/java/com/stripe/android/identity/navigation/UploadDestinations.kt b/identity/src/main/java/com/stripe/android/identity/navigation/UploadDestinations.kt deleted file mode 100644 index e14e62746a1..00000000000 --- a/identity/src/main/java/com/stripe/android/identity/navigation/UploadDestinations.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.stripe.android.identity.navigation - -import androidx.annotation.StringRes -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavType -import androidx.navigation.navArgument -import com.stripe.android.identity.R -import com.stripe.android.identity.networking.models.CollectedDataParam -import com.stripe.android.identity.states.IdentityScanState - -internal abstract class DocumentUploadDestination( - shouldPopUpToDocSelection: Boolean, - frontScanType: IdentityScanState.ScanType, - backScanType: IdentityScanState.ScanType, - @StringRes titleRes: Int, - @StringRes contextRes: Int, - @StringRes frontDescriptionRes: Int, - @StringRes frontCheckMarkDescriptionRes: Int, - @StringRes backDescriptionRes: Int = INVALID_STRING_RES, - @StringRes backCheckMarkDescriptionRes: Int = INVALID_STRING_RES, - collectedDataParamType: CollectedDataParam.Type -) : IdentityTopLevelDestination( - popUpToParam = if (shouldPopUpToDocSelection) { - PopUpToParam( - route = DocSelectionDestination.ROUTE.route, - inclusive = false - ) - } else { - null - } -) { - override val routeWithArgs by lazy { - destinationRoute.withParams( - ARG_FRONT_SCAN_TYPE to frontScanType, - ARG_BACK_SCAN_TYPE to backScanType, - ARG_TITLE_RES to titleRes, - ARG_CONTEXT_RES to contextRes, - ARG_FRONT_DESCRIPTION_RES to frontDescriptionRes, - ARG_FRONT_CHECK_MARK_DESCRIPTION_RES to frontCheckMarkDescriptionRes, - ARG_BACK_DESCRIPTION_RES to backDescriptionRes, - ARG_BACK_CHECK_MARK_DESCRIPTION_RES to backCheckMarkDescriptionRes, - ARG_COLLECTED_DATA_PARAM_TYPE to collectedDataParamType - ) - } - - companion object { - const val PASSPORT_UPLOAD = "PassportUpload" - val ROUTE = object : DestinationRoute() { - override val routeBase = PASSPORT_UPLOAD - } - - fun frontScanType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_FRONT_SCAN_TYPE) as IdentityScanState.ScanType - - fun backScanType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_BACK_SCAN_TYPE) as IdentityScanState.ScanType - - fun titleRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_TITLE_RES) - - fun contextRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_CONTEXT_RES) - - fun frontDescriptionRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_FRONT_DESCRIPTION_RES) - - fun frontCheckMarkDescriptionRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_FRONT_CHECK_MARK_DESCRIPTION_RES) - - fun backDescriptionRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_BACK_DESCRIPTION_RES) - - fun backCheckMarkDescriptionRes(backStackEntry: NavBackStackEntry) = - backStackEntry.getIntArgument(ARG_BACK_CHECK_MARK_DESCRIPTION_RES) - - fun collectedDataParamType(backStackEntry: NavBackStackEntry) = - backStackEntry.arguments?.getSerializable(ARG_COLLECTED_DATA_PARAM_TYPE) as CollectedDataParam.Type - } -} - -internal class DocumentUploadDestinationRoute( - override val routeBase: String -) : IdentityTopLevelDestination.DestinationRoute() { - override val arguments = mutableListOf( - navArgument(ARG_FRONT_SCAN_TYPE) { - type = NavType.EnumType(IdentityScanState.ScanType::class.java) - }, - navArgument(ARG_BACK_SCAN_TYPE) { - type = NavType.EnumType(IdentityScanState.ScanType::class.java) - }, - navArgument(ARG_TITLE_RES) { - type = NavType.IntType - }, - navArgument(ARG_CONTEXT_RES) { - type = NavType.IntType - }, - navArgument(ARG_FRONT_DESCRIPTION_RES) { - type = NavType.IntType - }, - navArgument(ARG_FRONT_CHECK_MARK_DESCRIPTION_RES) { - type = NavType.IntType - }, - navArgument(ARG_BACK_DESCRIPTION_RES) { - type = NavType.IntType - }, - navArgument(ARG_BACK_CHECK_MARK_DESCRIPTION_RES) { - type = NavType.IntType - }, - navArgument(ARG_COLLECTED_DATA_PARAM_TYPE) { - type = NavType.EnumType(CollectedDataParam.Type::class.java) - } - ) -} - -internal class PassportUploadDestination( - shouldPopUpToDocSelection: Boolean = false, -) : DocumentUploadDestination( - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.PASSPORT, - backScanType = IdentityScanState.ScanType.PASSPORT, - titleRes = R.string.stripe_upload_your_photo_id, - contextRes = R.string.stripe_file_upload_content_passport, - frontDescriptionRes = R.string.stripe_passport, - frontCheckMarkDescriptionRes = R.string.stripe_passport_selected, - collectedDataParamType = CollectedDataParam.Type.PASSPORT -) { - override val destinationRoute = ROUTE - - companion object { - private const val PASSPORT_UPLOAD = "PassportUpload" - val ROUTE = DocumentUploadDestinationRoute(routeBase = PASSPORT_UPLOAD) - } -} - -internal class IDUploadDestination( - shouldPopUpToDocSelection: Boolean = false -) : DocumentUploadDestination( - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.ID_FRONT, - backScanType = IdentityScanState.ScanType.ID_BACK, - titleRes = R.string.stripe_upload_your_photo_id, - contextRes = R.string.stripe_file_upload_content_id, - frontDescriptionRes = R.string.stripe_front_of_id, - frontCheckMarkDescriptionRes = R.string.stripe_front_of_id_selected, - backDescriptionRes = R.string.stripe_back_of_id, - backCheckMarkDescriptionRes = R.string.stripe_back_of_id_selected, - collectedDataParamType = CollectedDataParam.Type.IDCARD -) { - override val destinationRoute = ROUTE - - companion object { - private const val ID_UPLOAD = "IDUpload" - val ROUTE = DocumentUploadDestinationRoute(routeBase = ID_UPLOAD) - } -} - -internal class DriverLicenseUploadDestination( - shouldPopUpToDocSelection: Boolean = false -) : DocumentUploadDestination( - shouldPopUpToDocSelection = shouldPopUpToDocSelection, - frontScanType = IdentityScanState.ScanType.DL_FRONT, - backScanType = IdentityScanState.ScanType.DL_BACK, - titleRes = R.string.stripe_upload_your_photo_id, - contextRes = R.string.stripe_file_upload_content_dl, - frontDescriptionRes = R.string.stripe_front_of_dl, - frontCheckMarkDescriptionRes = R.string.stripe_front_of_dl_selected, - backDescriptionRes = R.string.stripe_back_of_dl, - backCheckMarkDescriptionRes = R.string.stripe_back_of_dl_selected, - collectedDataParamType = CollectedDataParam.Type.DRIVINGLICENSE -) { - override val destinationRoute = ROUTE - - companion object { - private const val DRIVE_LICENSE_UPLOAD = "DriverLicenseUpload" - val ROUTE = DocumentUploadDestinationRoute(routeBase = DRIVE_LICENSE_UPLOAD) - } -} - -internal const val ARG_TITLE_RES = "titleRes" -internal const val ARG_CONTEXT_RES = "contextRes" -internal const val ARG_FRONT_DESCRIPTION_RES = "frontDescriptionRes" -internal const val ARG_FRONT_CHECK_MARK_DESCRIPTION_RES = "frontCheckMarkDescriptionRes" -internal const val ARG_BACK_DESCRIPTION_RES = "backDescriptionRes" -internal const val ARG_BACK_CHECK_MARK_DESCRIPTION_RES = "backCheckMarkDescriptionRes" diff --git a/identity/src/main/java/com/stripe/android/identity/networking/models/CollectedDataParam.kt b/identity/src/main/java/com/stripe/android/identity/networking/models/CollectedDataParam.kt index 3b2fbee7ea6..1b974712a74 100644 --- a/identity/src/main/java/com/stripe/android/identity/networking/models/CollectedDataParam.kt +++ b/identity/src/main/java/com/stripe/android/identity/networking/models/CollectedDataParam.kt @@ -5,12 +5,6 @@ import android.os.Parcelable import com.stripe.android.core.networking.toMap import com.stripe.android.identity.R import com.stripe.android.identity.ml.IDDetectorAnalyzer -import com.stripe.android.identity.navigation.DriverLicenseScanDestination -import com.stripe.android.identity.navigation.DriverLicenseUploadDestination -import com.stripe.android.identity.navigation.IDScanDestination -import com.stripe.android.identity.navigation.IDUploadDestination -import com.stripe.android.identity.navigation.PassportScanDestination -import com.stripe.android.identity.navigation.PassportUploadDestination import com.stripe.android.identity.networking.UploadedResult import com.stripe.android.identity.ui.DRIVING_LICENSE_KEY import com.stripe.android.identity.ui.ID_CARD_KEY @@ -85,7 +79,6 @@ internal data class CollectedDataParam( ).toMap() fun createFromFrontUploadedResultsForAutoCapture( - type: Type, frontHighResResult: UploadedResult, frontLowResResult: UploadedResult ): CollectedDataParam = @@ -106,12 +99,10 @@ internal data class CollectedDataParam( "front low res image id is null" }, uploadMethod = DocumentUploadParam.UploadMethod.AUTOCAPTURE - ), - idDocumentType = type + ) ) fun createFromBackUploadedResultsForAutoCapture( - type: Type, backHighResResult: UploadedResult, backLowResResult: UploadedResult ): CollectedDataParam = @@ -132,8 +123,7 @@ internal data class CollectedDataParam( "back low res image id is null" }, uploadMethod = DocumentUploadParam.UploadMethod.AUTOCAPTURE - ), - idDocumentType = type + ) ) fun createForSelfie( @@ -234,47 +224,20 @@ internal data class CollectedDataParam( return requirements } - fun Type.toUploadDestination( - shouldPopUpToDocSelection: Boolean = false - ) = when (this) { - Type.IDCARD -> IDUploadDestination(shouldPopUpToDocSelection = shouldPopUpToDocSelection) - Type.DRIVINGLICENSE -> DriverLicenseUploadDestination(shouldPopUpToDocSelection = shouldPopUpToDocSelection) - Type.PASSPORT -> PassportUploadDestination(shouldPopUpToDocSelection = shouldPopUpToDocSelection) - else -> throw java.lang.IllegalStateException("Invalid CollectedDataParam.Type") - } - fun Type.toScanDestination( - shouldStartFromBack: Boolean = false, - shouldPopUpToDocSelection: Boolean = false - ) = when (this) { - Type.IDCARD -> IDScanDestination( - shouldStartFromBack, - shouldPopUpToDocSelection - ) - - Type.PASSPORT -> PassportScanDestination( - shouldStartFromBack, - shouldPopUpToDocSelection - ) - - Type.DRIVINGLICENSE -> DriverLicenseScanDestination( - shouldStartFromBack, - shouldPopUpToDocSelection - ) - - else -> throw IllegalStateException("Invalid CollectedDataParam.Type") - } - fun Type.getDisplayName(context: Context) = when (this) { Type.IDCARD -> { context.getString(R.string.stripe_id_card) } + Type.DRIVINGLICENSE -> { context.getString(R.string.stripe_driver_license) } + Type.PASSPORT -> { context.getString(R.string.stripe_passport) } + else -> throw java.lang.IllegalStateException("Invalid CollectedDataParam.Type") } } diff --git a/identity/src/main/java/com/stripe/android/identity/networking/models/Requirement.kt b/identity/src/main/java/com/stripe/android/identity/networking/models/Requirement.kt index 70f0f1d7154..90fb710e81b 100644 --- a/identity/src/main/java/com/stripe/android/identity/networking/models/Requirement.kt +++ b/identity/src/main/java/com/stripe/android/identity/networking/models/Requirement.kt @@ -4,16 +4,12 @@ import android.content.Context import com.stripe.android.identity.navigation.ConfirmationDestination import com.stripe.android.identity.navigation.ConsentDestination import com.stripe.android.identity.navigation.DocSelectionDestination -import com.stripe.android.identity.navigation.DriverLicenseScanDestination -import com.stripe.android.identity.navigation.DriverLicenseUploadDestination -import com.stripe.android.identity.navigation.IDScanDestination -import com.stripe.android.identity.navigation.IDUploadDestination +import com.stripe.android.identity.navigation.DocumentScanDestination +import com.stripe.android.identity.navigation.DocumentUploadDestination import com.stripe.android.identity.navigation.IdentityTopLevelDestination import com.stripe.android.identity.navigation.IndividualDestination import com.stripe.android.identity.navigation.IndividualWelcomeDestination import com.stripe.android.identity.navigation.OTPDestination -import com.stripe.android.identity.navigation.PassportScanDestination -import com.stripe.android.identity.navigation.PassportUploadDestination import com.stripe.android.identity.navigation.SelfieDestination import com.stripe.android.identity.navigation.finalErrorDestination import kotlinx.serialization.SerialName @@ -55,14 +51,6 @@ internal enum class Requirement { PHONE_OTP; internal companion object { - private val SCAN_UPLOAD_ROUTE_SET = setOf( - DriverLicenseUploadDestination.ROUTE, - IDUploadDestination.ROUTE, - PassportUploadDestination.ROUTE, - DriverLicenseScanDestination.ROUTE, - IDScanDestination.ROUTE, - PassportScanDestination.ROUTE - ) val INDIVIDUAL_REQUIREMENT_SET = setOf( NAME, @@ -86,15 +74,13 @@ internal enum class Requirement { } IDDOCUMENTBACK -> { - SCAN_UPLOAD_ROUTE_SET.any { - it.route == fromRoute - } + fromRoute == DocumentScanDestination.ROUTE.route || + fromRoute == DocumentUploadDestination.ROUTE.route } IDDOCUMENTFRONT -> { - SCAN_UPLOAD_ROUTE_SET.any { - it.route == fromRoute - } + fromRoute == DocumentScanDestination.ROUTE.route || + fromRoute == DocumentUploadDestination.ROUTE.route } IDDOCUMENTTYPE -> { diff --git a/identity/src/main/java/com/stripe/android/identity/states/IDDetectorTransitioner.kt b/identity/src/main/java/com/stripe/android/identity/states/IDDetectorTransitioner.kt index eb937b4e643..f89d91965d0 100644 --- a/identity/src/main/java/com/stripe/android/identity/states/IDDetectorTransitioner.kt +++ b/identity/src/main/java/com/stripe/android/identity/states/IDDetectorTransitioner.kt @@ -268,10 +268,10 @@ internal class IDDetectorTransitioner( * Note: the ML model will output ID_FRONT or ID_BACK for both ID and Driver License. */ private fun Category.matchesScanType(scanType: ScanType): Boolean { - return this == Category.ID_BACK && scanType == ScanType.ID_BACK || - this == Category.ID_FRONT && scanType == ScanType.ID_FRONT || - this == Category.ID_BACK && scanType == ScanType.DL_BACK || - this == Category.ID_FRONT && scanType == ScanType.DL_FRONT || - this == Category.PASSPORT && scanType == ScanType.PASSPORT + return this == Category.ID_BACK && scanType == ScanType.DOC_BACK || + this == Category.ID_FRONT && scanType == ScanType.DOC_FRONT || + this == Category.ID_BACK && scanType == ScanType.DOC_BACK || + this == Category.ID_FRONT && scanType == ScanType.DOC_FRONT || + this == Category.PASSPORT && scanType == ScanType.DOC_FRONT } } diff --git a/identity/src/main/java/com/stripe/android/identity/states/IdentityScanState.kt b/identity/src/main/java/com/stripe/android/identity/states/IdentityScanState.kt index f1eb6f1ddc6..6eee7e76119 100644 --- a/identity/src/main/java/com/stripe/android/identity/states/IdentityScanState.kt +++ b/identity/src/main/java/com/stripe/android/identity/states/IdentityScanState.kt @@ -5,13 +5,6 @@ import com.stripe.android.camera.framework.time.ClockMark import com.stripe.android.camera.scanui.ScanState import com.stripe.android.identity.ml.AnalyzerInput import com.stripe.android.identity.ml.AnalyzerOutput -import com.stripe.android.identity.navigation.DriverLicenseScanDestination -import com.stripe.android.identity.navigation.DriverLicenseUploadDestination -import com.stripe.android.identity.navigation.IDScanDestination -import com.stripe.android.identity.navigation.IDUploadDestination -import com.stripe.android.identity.navigation.PassportScanDestination -import com.stripe.android.identity.navigation.PassportUploadDestination -import com.stripe.android.identity.navigation.SelfieDestination /** * States during scanning a document. @@ -26,11 +19,8 @@ internal sealed class IdentityScanState( * Type of documents being scanned */ enum class ScanType { - ID_FRONT, - ID_BACK, - DL_FRONT, - DL_BACK, - PASSPORT, + DOC_FRONT, + DOC_BACK, SELFIE } @@ -135,59 +125,11 @@ internal sealed class IdentityScanState( internal companion object { fun ScanType.isFront() = - this == ScanType.ID_FRONT || this == ScanType.DL_FRONT || this == ScanType.PASSPORT + this == ScanType.DOC_FRONT fun ScanType.isBack() = - this == ScanType.ID_BACK || this == ScanType.DL_BACK + this == ScanType.DOC_BACK fun ScanType?.isNullOrFront() = this == null || this.isFront() - - fun ScanType.toUploadDestination() = - when (this) { - ScanType.ID_FRONT -> - IDUploadDestination(true) - ScanType.ID_BACK -> - IDUploadDestination(true) - ScanType.DL_FRONT -> - DriverLicenseUploadDestination(true) - ScanType.DL_BACK -> - DriverLicenseUploadDestination(true) - ScanType.PASSPORT -> - PassportUploadDestination(true) - ScanType.SELFIE -> { - throw IllegalArgumentException("SELFIE doesn't support upload") - } - } - - fun ScanType.toScanDestination() = - when (this) { - ScanType.ID_FRONT -> - IDScanDestination( - shouldStartFromBack = false, - shouldPopUpToDocSelection = true - ) - ScanType.ID_BACK -> - IDScanDestination( - shouldStartFromBack = true, - shouldPopUpToDocSelection = true - ) - ScanType.DL_FRONT -> - DriverLicenseScanDestination( - shouldStartFromBack = false, - shouldPopUpToDocSelection = true - ) - ScanType.DL_BACK -> - DriverLicenseScanDestination( - shouldStartFromBack = true, - shouldPopUpToDocSelection = true - ) - ScanType.PASSPORT -> - PassportScanDestination( - shouldStartFromBack = false, - shouldPopUpToDocSelection = true - ) - ScanType.SELFIE -> - SelfieDestination - } } } diff --git a/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffect.kt b/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffect.kt index db8d986a466..b31e6f07167 100644 --- a/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffect.kt +++ b/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffect.kt @@ -113,8 +113,7 @@ internal fun CameraScreenLaunchedEffect( } identityViewModel.uploadScanResult( finalResult, - verificationPage, - identityScanViewModel.targetScanTypeFlow.value + verificationPage ) } // Transition to CouldNotCaptureDestination @@ -136,15 +135,7 @@ internal fun CameraScreenLaunchedEffect( navController.navigateTo( CouldNotCaptureDestination( - scanType = requireNotNull(identityScanViewModel.targetScanTypeFlow.value), - requireLiveCapture = - if (identityScanViewModel.targetScanTypeFlow.value - != IdentityScanState.ScanType.SELFIE - ) { - verificationPage.documentCapture.requireLiveCapture - } else { - false - } + fromSelfie = finalResult.result is FaceDetectorOutput ) ) } diff --git a/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectLight.kt b/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectLight.kt new file mode 100644 index 00000000000..08a6d98a412 --- /dev/null +++ b/identity/src/main/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectLight.kt @@ -0,0 +1,106 @@ +package com.stripe.android.identity.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory +import com.stripe.android.identity.ml.FaceDetectorOutput +import com.stripe.android.identity.ml.IDDetectorOutput +import com.stripe.android.identity.navigation.CouldNotCaptureDestination +import com.stripe.android.identity.navigation.navigateTo +import com.stripe.android.identity.networking.models.VerificationPage +import com.stripe.android.identity.states.IdentityScanState +import com.stripe.android.identity.states.IdentityScanState.Companion.isBack +import com.stripe.android.identity.states.IdentityScanState.Companion.isFront +import com.stripe.android.identity.viewmodel.IdentityScanViewModel +import com.stripe.android.identity.viewmodel.IdentityViewModel +import kotlinx.coroutines.launch + +/** + * [CameraScreenLaunchedEffect] without checking pageAndModelFiles from IdentityViewModel. + */ +@Composable +internal fun CameraScreenLaunchedEffectLight( + identityViewModel: IdentityViewModel, + identityScanViewModel: IdentityScanViewModel, + verificationPage: VerificationPage, + navController: NavController +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(identityScanViewModel) { + // Handles interim result - track FPS + identityScanViewModel.interimResults.observe(lifecycleOwner) { + identityViewModel.fpsTracker.trackFrame() + } + + // Handles final result - upload if success, transition to CouldNotCapture if timeout + identityScanViewModel.finalResult.observe(lifecycleOwner) { finalResult -> + lifecycleOwner.lifecycleScope.launch { + identityViewModel.fpsTracker.reportAndReset( + if (finalResult.result is FaceDetectorOutput) { + IdentityAnalyticsRequestFactory.TYPE_SELFIE + } else { + IdentityAnalyticsRequestFactory.TYPE_DOCUMENT + } + ) + } + + // Upload success result + if (finalResult.identityState is IdentityScanState.Finished) { + when (finalResult.result) { + is FaceDetectorOutput -> { + identityViewModel.updateAnalyticsState { oldState -> + oldState.copy(selfieModelScore = finalResult.result.resultScore) + } + } + is IDDetectorOutput -> { + if (finalResult.identityState.type.isFront()) { + identityViewModel.updateAnalyticsState { oldState -> + oldState.copy( + docFrontModelScore = finalResult.result.resultScore, + docFrontBlurScore = finalResult.result.blurScore + ) + } + } else if (finalResult.identityState.type.isBack()) { + identityViewModel.updateAnalyticsState { oldState -> + oldState.copy( + docBackModelScore = finalResult.result.resultScore, + docBackBlurScore = finalResult.result.blurScore + ) + } + } + } + } + identityViewModel.uploadScanResult( + finalResult, + verificationPage + ) + } + // Transition to CouldNotCaptureDestination + else if (finalResult.identityState is IdentityScanState.TimeOut) { + when (finalResult.result) { + is FaceDetectorOutput -> { + identityViewModel.sendAnalyticsRequest( + identityViewModel.identityAnalyticsRequestFactory.selfieTimeout() + ) + } + is IDDetectorOutput -> { + identityViewModel.sendAnalyticsRequest( + identityViewModel.identityAnalyticsRequestFactory.documentTimeout( + scanType = finalResult.identityState.type + ) + ) + } + } + + navController.navigateTo( + CouldNotCaptureDestination( + fromSelfie = finalResult.result is FaceDetectorOutput + ) + ) + } + } + } +} diff --git a/identity/src/main/java/com/stripe/android/identity/ui/DocumenetScanScreen.kt b/identity/src/main/java/com/stripe/android/identity/ui/DocumenetScanScreen.kt deleted file mode 100644 index 25c9cf960af..00000000000 --- a/identity/src/main/java/com/stripe/android/identity/ui/DocumenetScanScreen.kt +++ /dev/null @@ -1,336 +0,0 @@ -package com.stripe.android.identity.ui - -import androidx.annotation.StringRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.navigation.NavController -import com.stripe.android.camera.scanui.CameraView -import com.stripe.android.identity.R -import com.stripe.android.identity.camera.DocumentScanCameraManager -import com.stripe.android.identity.camera.IdentityCameraManager -import com.stripe.android.identity.navigation.navigateToErrorScreenWithDefaultValues -import com.stripe.android.identity.navigation.routeToScreenName -import com.stripe.android.identity.networking.Resource -import com.stripe.android.identity.networking.models.CollectedDataParam -import com.stripe.android.identity.states.IdentityScanState -import com.stripe.android.identity.states.IdentityScanState.Companion.isFront -import com.stripe.android.identity.states.IdentityScanState.Companion.isNullOrFront -import com.stripe.android.identity.utils.startScanning -import com.stripe.android.identity.viewmodel.IdentityScanViewModel -import com.stripe.android.identity.viewmodel.IdentityViewModel -import kotlinx.coroutines.launch - -internal const val CONTINUE_BUTTON_TAG = "Continue" -internal const val SCAN_TITLE_TAG = "Title" -internal const val SCAN_MESSAGE_TAG = "Message" -internal const val CHECK_MARK_TAG = "CheckMark" -internal const val VIEW_FINDER_ASPECT_RATIO = 1.5f - -internal data class DocumentScanMessageRes( - @StringRes - val frontTitleStringRes: Int, - @StringRes - val backTitleStringRes: Int, - @StringRes - val frontMessageStringRes: Int, - @StringRes - val backMessageStringRes: Int -) - -@Composable -internal fun DocumentScanScreen( - navController: NavController, - identityViewModel: IdentityViewModel, - identityScanViewModel: IdentityScanViewModel, - frontScanType: IdentityScanState.ScanType, - backScanType: IdentityScanState.ScanType?, - shouldStartFromBack: Boolean, - messageRes: DocumentScanMessageRes, - collectedDataParamType: CollectedDataParam.Type, - route: String -) { - val changedDisplayState by identityScanViewModel.displayStateChangedFlow.collectAsState() - val newDisplayState by remember { - derivedStateOf { - changedDisplayState?.first - } - } - val verificationPageState by identityViewModel.verificationPage.observeAsState(Resource.loading()) - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - - CheckVerificationPageAndCompose( - verificationPageResource = verificationPageState, - onError = { - identityViewModel.errorCause.postValue(it) - navController.navigateToErrorScreenWithDefaultValues(context) - } - ) { verificationPage -> - val cameraManager = remember { - DocumentScanCameraManager( - context = context - ) { cause -> - identityViewModel.sendAnalyticsRequest( - identityViewModel.identityAnalyticsRequestFactory.cameraError( - scanType = frontScanType, - throwable = IllegalStateException(cause) - ) - ) - } - } - - val lifecycleOwner = LocalLifecycleOwner.current - - val targetScanType by identityScanViewModel.targetScanTypeFlow.collectAsState() - - val title = if (targetScanType.isNullOrFront()) { - stringResource(id = messageRes.frontTitleStringRes) - } else { - stringResource(id = messageRes.backTitleStringRes) - } - - val message = when (newDisplayState) { - is IdentityScanState.Finished -> stringResource(id = R.string.stripe_scanned) - is IdentityScanState.Found -> stringResource(id = R.string.stripe_hold_still) - is IdentityScanState.Initial -> { - if (targetScanType.isNullOrFront()) { - stringResource(id = messageRes.frontMessageStringRes) - } else { - stringResource(id = messageRes.backMessageStringRes) - } - } - - is IdentityScanState.Satisfied -> stringResource(id = R.string.stripe_scanned) - is IdentityScanState.TimeOut -> "" - is IdentityScanState.Unsatisfied -> "" - null -> { - if (targetScanType.isNullOrFront()) { - stringResource(id = messageRes.frontMessageStringRes) - } else { - stringResource(id = messageRes.backMessageStringRes) - } - } - } - - LaunchedEffect(newDisplayState) { - when (newDisplayState) { - null -> { - cameraManager.toggleInitial() - } - is IdentityScanState.Initial -> { - cameraManager.toggleInitial() - } - is IdentityScanState.Found -> { - cameraManager.toggleFound() - } - is IdentityScanState.Finished -> { - identityScanViewModel.stopScan(lifecycleOwner) - cameraManager.toggleFinished() - } - else -> {} // no-op - } - } - - LaunchedEffect(Unit) { - if (shouldStartFromBack) { - identityViewModel.resetDocumentUploadedState() - } - } - - CameraScreenLaunchedEffect( - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - verificationPage = verificationPage, - navController = navController, - cameraManager = cameraManager - ) { - if (shouldStartFromBack) { - startScanning( - scanType = requireNotNull(backScanType) { - "$backScanType should not be null when trying to scan from back" - }, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - lifecycleOwner = lifecycleOwner - ) - } else { - startScanning( - scanType = frontScanType, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - lifecycleOwner = lifecycleOwner - ) - } - } - - ScreenTransitionLaunchedEffect( - identityViewModel = identityViewModel, - scanType = frontScanType, - screenName = route.routeToScreenName() - ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding( - vertical = dimensionResource(id = R.dimen.stripe_page_vertical_margin), - horizontal = dimensionResource(id = R.dimen.stripe_page_horizontal_margin) - ) - ) { - var loadingButtonState by remember(newDisplayState) { - mutableStateOf( - if (newDisplayState is IdentityScanState.Finished) { - LoadingButtonState.Idle - } else { - LoadingButtonState.Disabled - } - ) - } - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - Text( - text = title, - modifier = Modifier - .fillMaxWidth() - .semantics { - testTag = SCAN_TITLE_TAG - }, - fontSize = 24.sp, - fontWeight = FontWeight.Bold - ) - Text( - text = message, - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - .padding( - top = dimensionResource(id = R.dimen.stripe_item_vertical_margin), - bottom = 48.dp - ) - .semantics { - testTag = SCAN_MESSAGE_TAG - }, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - CameraViewFinder(newDisplayState, cameraManager) - } - LoadingButton( - modifier = Modifier.testTag(CONTINUE_BUTTON_TAG), - text = stringResource(id = R.string.stripe_kontinue).uppercase(), - state = loadingButtonState - ) { - loadingButtonState = LoadingButtonState.Loading - - coroutineScope.launch { - identityViewModel.collectDataForDocumentScanScreen( - navController = navController, - isFront = requireNotNull(targetScanType) { - "targetScanType is still null" - }.isFront(), - collectedDataParamType = collectedDataParamType, - route = route - ) { - startScanning( - scanType = requireNotNull(backScanType) { - "backScanType is null while still missing back" - }, - identityViewModel = identityViewModel, - identityScanViewModel = identityScanViewModel, - lifecycleOwner = lifecycleOwner - ) - } - } - } - } - } -} - -@Composable -private fun CameraViewFinder( - newScanState: IdentityScanState?, - cameraManager: IdentityCameraManager -) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(VIEW_FINDER_ASPECT_RATIO) - .clip(RoundedCornerShape(dimensionResource(id = R.dimen.stripe_view_finder_corner_radius))) - ) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { - CameraView( - it, - CameraView.ViewFinderType.ID, - R.drawable.stripe_viewfinder_border_initial - ) - }, - update = - { - cameraManager.onCameraViewUpdate(it) - } - ) - if (newScanState is IdentityScanState.Finished) { - Box( - modifier = Modifier - .fillMaxSize() - .background( - colorResource(id = R.color.stripe_check_mark_background) - ) - .testTag(CHECK_MARK_TAG) - ) { - Image( - modifier = Modifier - .fillMaxSize() - .padding(60.dp), - painter = painterResource(id = R.drawable.stripe_check_mark), - contentDescription = stringResource(id = R.string.stripe_check_mark), - colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), - ) - } - } - } -} diff --git a/identity/src/main/java/com/stripe/android/identity/ui/DocumentScanScreen.kt b/identity/src/main/java/com/stripe/android/identity/ui/DocumentScanScreen.kt new file mode 100644 index 00000000000..76c32341598 --- /dev/null +++ b/identity/src/main/java/com/stripe/android/identity/ui/DocumentScanScreen.kt @@ -0,0 +1,349 @@ +package com.stripe.android.identity.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import com.stripe.android.camera.scanui.CameraView +import com.stripe.android.identity.R +import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory.Companion.SCREEN_NAME_LIVE_CAPTURE +import com.stripe.android.identity.camera.DocumentScanCameraManager +import com.stripe.android.identity.camera.IdentityCameraManager +import com.stripe.android.identity.states.IdentityScanState +import com.stripe.android.identity.states.IdentityScanState.Companion.isFront +import com.stripe.android.identity.states.IdentityScanState.Companion.isNullOrFront +import com.stripe.android.identity.utils.startScanning +import com.stripe.android.identity.viewmodel.IdentityScanViewModel +import com.stripe.android.identity.viewmodel.IdentityViewModel +import kotlinx.coroutines.launch + +internal const val CONTINUE_BUTTON_TAG = "Continue" +internal const val SCAN_TITLE_TAG = "Title" +internal const val SCAN_MESSAGE_TAG = "Message" +internal const val CHECK_MARK_TAG = "CheckMark" +internal const val VIEW_FINDER_ASPECT_RATIO = 1.5f + +@Composable +internal fun DocumentScanScreen( + navController: NavController, + identityViewModel: IdentityViewModel, + identityScanViewModel: IdentityScanViewModel +) { + val changedDisplayState by identityScanViewModel.displayStateChangedFlow.collectAsState() + val newDisplayState by remember { + derivedStateOf { + changedDisplayState?.first + } + } + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val cameraManager = remember { + DocumentScanCameraManager( + context = context + ) { cause -> + identityViewModel.sendAnalyticsRequest( + identityViewModel.identityAnalyticsRequestFactory.cameraError( + scanType = IdentityScanState.ScanType.DOC_FRONT, + throwable = IllegalStateException(cause) + ) + ) + } + } + + CheckVerificationPageModelFilesAndCompose( + identityViewModel = identityViewModel, + navController = navController + ) { pageAndModelFiles -> + + val targetScanType by identityScanViewModel.targetScanTypeFlow.collectAsState() + + ScreenTransitionLaunchedEffect( + identityViewModel = identityViewModel, + screenName = SCREEN_NAME_LIVE_CAPTURE + ) + + // run once to initialize + LaunchedEffect(Unit) { + identityScanViewModel.initializeScanFlowAndUpdateState(pageAndModelFiles, cameraManager) + } + + val documentScannerState by identityScanViewModel.scannerState.collectAsState() + + when (documentScannerState) { + IdentityScanViewModel.State.Initial -> { + LoadingScreen() + } + + IdentityScanViewModel.State.Initializing -> { + LoadingScreen() + } + // TODO(IDPROD-6555) - separate Initialized, Scanning and Scanned + else -> { + // TODO(IDPROD-6555) Remove this and handle toggling inside IdentityScanViewModel + LaunchedEffect(newDisplayState) { + when (newDisplayState) { + null -> { + cameraManager.toggleInitial() + } + + is IdentityScanState.Initial -> { + cameraManager.toggleInitial() + } + + is IdentityScanState.Found -> { + cameraManager.toggleFound() + } + + is IdentityScanState.Finished -> { + identityScanViewModel.stopScan(lifecycleOwner) + cameraManager.toggleFinished() + } + + else -> {} // no-op + } + } + CameraScreenLaunchedEffectLight( + identityViewModel = identityViewModel, + identityScanViewModel = identityScanViewModel, + verificationPage = pageAndModelFiles.page, + navController = navController + ) + + ScanScreen( + newDisplayState, + documentScannerState, + targetScanType, + identityScanViewModel, + identityViewModel, + lifecycleOwner, + cameraManager + ) { + coroutineScope.launch { + identityViewModel.collectDataForDocumentScanScreen( + navController = navController, + isFront = requireNotNull(targetScanType) { + "targetScanType is still null" + }.isFront() + ) { + startScanning( + scanType = IdentityScanState.ScanType.DOC_BACK, + identityViewModel = identityViewModel, + identityScanViewModel = identityScanViewModel, + lifecycleOwner = lifecycleOwner + ) + } + } + } + } + } + } +} + +@Composable +private fun ScanScreen( + newDisplayState: IdentityScanState?, + documentScannerState: IdentityScanViewModel.State, + targetScanType: IdentityScanState.ScanType?, + identityScanViewModel: IdentityScanViewModel, + identityViewModel: IdentityViewModel, + lifecycleOwner: LifecycleOwner, + cameraManager: IdentityCameraManager, + onContinueClick: () -> Unit +) { + val collectedData by identityViewModel.collectedData.collectAsState() + + LaunchedEffect(Unit) { + val shouldStartFromBack = collectedData.idDocumentFront != null + // start scan - this is only triggered once + if (shouldStartFromBack) { + startScanning( + scanType = IdentityScanState.ScanType.DOC_BACK, + identityViewModel = identityViewModel, + identityScanViewModel = identityScanViewModel, + lifecycleOwner = lifecycleOwner + ) + } else { + startScanning( + scanType = IdentityScanState.ScanType.DOC_FRONT, + identityViewModel = identityViewModel, + identityScanViewModel = identityScanViewModel, + lifecycleOwner = lifecycleOwner + ) + } + } + + val title = if (targetScanType.isNullOrFront()) { + stringResource(id = R.string.stripe_front_of_id) + } else { + stringResource(id = R.string.stripe_back_of_id) + } + + val message = when (newDisplayState) { + is IdentityScanState.Finished -> stringResource(id = R.string.stripe_scanned) + is IdentityScanState.Found -> stringResource(id = R.string.stripe_hold_still) + is IdentityScanState.Initial -> { + if (targetScanType.isNullOrFront()) { + stringResource(id = R.string.stripe_position_id_front) + } else { + stringResource(id = R.string.stripe_position_id_back) + } + } + + is IdentityScanState.Satisfied -> stringResource(id = R.string.stripe_scanned) + is IdentityScanState.TimeOut -> "" + is IdentityScanState.Unsatisfied -> "" + null -> { + if (targetScanType.isNullOrFront()) { + stringResource(id = R.string.stripe_position_id_front) + } else { + stringResource(id = R.string.stripe_position_id_back) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + vertical = dimensionResource(id = R.dimen.stripe_page_vertical_margin), + horizontal = dimensionResource(id = R.dimen.stripe_page_horizontal_margin) + ) + ) { + var loadingButtonState by remember(documentScannerState) { + mutableStateOf( + if (documentScannerState is IdentityScanViewModel.State.Scanned) { + LoadingButtonState.Idle + } else { + LoadingButtonState.Disabled + } + ) + } + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .semantics { + testTag = SCAN_TITLE_TAG + }, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = message, + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .padding( + top = dimensionResource(id = R.dimen.stripe_item_vertical_margin), + bottom = 48.dp + ) + .semantics { + testTag = SCAN_MESSAGE_TAG + }, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + CameraViewFinder(newDisplayState, cameraManager) + } + LoadingButton( + modifier = Modifier.testTag(CONTINUE_BUTTON_TAG), + text = stringResource(id = R.string.stripe_kontinue).uppercase(), + state = loadingButtonState + ) { + loadingButtonState = LoadingButtonState.Loading + onContinueClick() + } + } +} + +@Composable +private fun CameraViewFinder( + newScanState: IdentityScanState?, + cameraManager: IdentityCameraManager +) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(VIEW_FINDER_ASPECT_RATIO) + .clip(RoundedCornerShape(dimensionResource(id = R.dimen.stripe_view_finder_corner_radius))) + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + CameraView( + it, + CameraView.ViewFinderType.ID, + R.drawable.stripe_viewfinder_border_initial + ) + }, + update = + { + cameraManager.onCameraViewUpdate(it) + } + ) + if (newScanState is IdentityScanState.Finished) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + colorResource(id = R.color.stripe_check_mark_background) + ) + .testTag(CHECK_MARK_TAG) + ) { + Image( + modifier = Modifier + .fillMaxSize() + .padding(60.dp), + painter = painterResource(id = R.drawable.stripe_check_mark), + contentDescription = stringResource(id = R.string.stripe_check_mark), + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary), + ) + } + } + } +} diff --git a/identity/src/main/java/com/stripe/android/identity/ui/LoadingScreen.kt b/identity/src/main/java/com/stripe/android/identity/ui/LoadingScreen.kt index ff4f8b787c4..19422ed26b5 100644 --- a/identity/src/main/java/com/stripe/android/identity/ui/LoadingScreen.kt +++ b/identity/src/main/java/com/stripe/android/identity/ui/LoadingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.semantics.testTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import com.stripe.android.core.exception.InvalidResponseException import com.stripe.android.identity.R import com.stripe.android.identity.navigation.navigateToErrorScreenWithDefaultValues import com.stripe.android.identity.networking.Resource @@ -90,3 +91,31 @@ internal fun CheckVerificationPageAndCompose( } } } + +@Composable +internal fun CheckVerificationPageModelFilesAndCompose( + identityViewModel: IdentityViewModel, + navController: NavController, + onSuccess: @Composable (IdentityViewModel.PageAndModelFiles) -> Unit +) { + val verificationPageState by identityViewModel.pageAndModelFiles.observeAsState(Resource.loading()) + val context = LocalContext.current + when (verificationPageState.status) { + Status.SUCCESS -> { + onSuccess(requireNotNull(verificationPageState.data)) + } + Status.LOADING -> { + LoadingScreen() + } // no-op + Status.IDLE -> {} // no-op + Status.ERROR -> { + identityViewModel.errorCause.postValue( + InvalidResponseException( + cause = verificationPageState.throwable, + message = verificationPageState.message + ) + ) + navController.navigateToErrorScreenWithDefaultValues(context) + } + } +} diff --git a/identity/src/main/java/com/stripe/android/identity/ui/UploadScreen.kt b/identity/src/main/java/com/stripe/android/identity/ui/UploadScreen.kt index c22dc511dce..dff529202ae 100644 --- a/identity/src/main/java/com/stripe/android/identity/ui/UploadScreen.kt +++ b/identity/src/main/java/com/stripe/android/identity/ui/UploadScreen.kt @@ -1,6 +1,5 @@ package com.stripe.android.identity.ui -import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -49,13 +48,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.navigation.NavController import com.stripe.android.identity.R +import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory.Companion.SCREEN_NAME_FILE_UPLOAD +import com.stripe.android.identity.navigation.DocumentUploadDestination import com.stripe.android.identity.navigation.navigateToFinalErrorScreen -import com.stripe.android.identity.navigation.routeToScreenName import com.stripe.android.identity.networking.Resource import com.stripe.android.identity.networking.Status -import com.stripe.android.identity.networking.models.CollectedDataParam import com.stripe.android.identity.networking.models.Requirement -import com.stripe.android.identity.states.IdentityScanState import com.stripe.android.identity.viewmodel.IdentityViewModel import com.stripe.android.uicore.text.dimensionResourceSp import kotlinx.coroutines.launch @@ -71,24 +69,10 @@ internal enum class UploadMethod { TAKE_PHOTO, CHOOSE_PHOTO } -internal data class DocumentUploadSideInfo( - @StringRes - val descriptionRes: Int, - @StringRes - val checkmarkContentDescriptionRes: Int, - val scanType: IdentityScanState.ScanType -) - @Composable internal fun UploadScreen( navController: NavController, identityViewModel: IdentityViewModel, - collectedDataParamType: CollectedDataParam.Type, - route: String, - @StringRes titleRes: Int, - @StringRes contextRes: Int, - frontInfo: DocumentUploadSideInfo, - backInfo: DocumentUploadSideInfo? ) { val localContext = LocalContext.current val verificationState by identityViewModel.verificationPage.observeAsState(Resource.loading()) @@ -113,8 +97,6 @@ internal fun UploadScreen( launch { identityViewModel.collectDataForDocumentUploadScreen( navController, - collectedDataParamType, - route, isFront = true ) } @@ -122,8 +104,6 @@ internal fun UploadScreen( launch { identityViewModel.collectDataForDocumentUploadScreen( navController, - collectedDataParamType, - route, isFront = false ) } @@ -131,8 +111,7 @@ internal fun UploadScreen( ScreenTransitionLaunchedEffect( identityViewModel = identityViewModel, - screenName = route.routeToScreenName(), - scanType = frontInfo.scanType + screenName = SCREEN_NAME_FILE_UPLOAD ) Column( @@ -148,7 +127,7 @@ internal fun UploadScreen( .testTag(SCROLLABLE_COLUMN_TAG) ) { Text( - text = stringResource(id = titleRes), + text = stringResource(id = R.string.stripe_upload_your_photo_id), fontSize = dimensionResourceSp(id = R.dimen.stripe_upload_title_text_size), modifier = Modifier.padding( @@ -156,7 +135,7 @@ internal fun UploadScreen( ) ) Text( - text = stringResource(id = contextRes), + text = stringResource(id = R.string.stripe_file_upload_content_id), modifier = Modifier.padding( bottom = 32.dp ) @@ -181,13 +160,13 @@ internal fun UploadScreen( var shouldShowFrontDialog by remember { mutableStateOf(false) } SingleSideUploadRow( modifier = Modifier.testTag(FRONT_ROW_TAG), + isFront = true, uploadUiState = frontUploadedUiState, - uploadInfo = frontInfo, ) { shouldShowFrontDialog = true } if (shouldShowFrontDialog) { UploadImageDialog( - uploadInfo = frontInfo, + isFront = true, shouldShowTakePhoto = cameraPermissionGranted, shouldShowChoosePhoto = shouldShowChoosePhoto, onPhotoSelected = { uploadMethod -> @@ -195,6 +174,7 @@ internal fun UploadScreen( UploadMethod.TAKE_PHOTO -> { identityViewModel.imageHandler.takePhotoFront(localContext) } + UploadMethod.CHOOSE_PHOTO -> { identityViewModel.imageHandler.chooseImageFront() } @@ -213,11 +193,10 @@ internal fun UploadScreen( true } // Otherwise show back when all the follows are true - // * backInfo is not null - e.g not a passport scan where backInfo is null // * collectedData.idDocumentFront not null - front is already scanned // * missing BACK - front already scanned and server returns missing back else { - backInfo != null && collectedData.idDocumentFront != null && missings.contains( + collectedData.idDocumentFront != null && missings.contains( Requirement.IDDOCUMENTBACK ) } @@ -248,13 +227,13 @@ internal fun UploadScreen( } SingleSideUploadRow( modifier = Modifier.testTag(BACK_ROW_TAG), + isFront = false, uploadUiState = backUploadedUiState, - uploadInfo = requireNotNull(backInfo), ) { shouldShowBackDialog = true } if (shouldShowBackDialog) { UploadImageDialog( - uploadInfo = backInfo, + isFront = false, shouldShowTakePhoto = cameraPermissionGranted, shouldShowChoosePhoto = shouldShowChoosePhoto, onPhotoSelected = { uploadMethod -> @@ -262,6 +241,7 @@ internal fun UploadScreen( UploadMethod.TAKE_PHOTO -> { identityViewModel.imageHandler.takePhotoBack(localContext) } + UploadMethod.CHOOSE_PHOTO -> { identityViewModel.imageHandler.chooseImageBack() } @@ -296,7 +276,7 @@ internal fun UploadScreen( coroutineScope.launch { identityViewModel.navigateToSelfieOrSubmit( navController, - route + DocumentUploadDestination.ROUTE.route ) } } @@ -306,7 +286,7 @@ internal fun UploadScreen( @Composable internal fun UploadImageDialog( - uploadInfo: DocumentUploadSideInfo, + isFront: Boolean, shouldShowTakePhoto: Boolean, shouldShowChoosePhoto: Boolean, onPhotoSelected: (UploadMethod) -> Unit, @@ -333,7 +313,9 @@ internal fun UploadImageDialog( start = 24.dp, end = 24.dp ), - text = getTitleFromScanType(scanType = uploadInfo.scanType), + text = stringResource( + id = if (isFront) R.string.stripe_front_of_id else R.string.stripe_back_of_id + ), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold ) @@ -388,30 +370,6 @@ private fun DialogListItem( } } -@Composable -private fun getTitleFromScanType(scanType: IdentityScanState.ScanType): String { - return when (scanType) { - IdentityScanState.ScanType.ID_FRONT -> { - stringResource(R.string.stripe_front_of_id) - } - IdentityScanState.ScanType.ID_BACK -> { - stringResource(R.string.stripe_back_of_id) - } - IdentityScanState.ScanType.DL_FRONT -> { - stringResource(R.string.stripe_front_of_dl) - } - IdentityScanState.ScanType.DL_BACK -> { - stringResource(R.string.stripe_back_of_dl) - } - IdentityScanState.ScanType.PASSPORT -> { - stringResource(R.string.stripe_passport) - } - else -> { - throw java.lang.IllegalArgumentException("invalid scan type: $scanType") - } - } -} - private enum class DocumentUploadUIState { Idle, Loading, Done } @@ -419,8 +377,8 @@ private enum class DocumentUploadUIState { @Composable private fun SingleSideUploadRow( modifier: Modifier = Modifier, + isFront: Boolean, uploadUiState: DocumentUploadUIState, - uploadInfo: DocumentUploadSideInfo, onSelectButtonClicked: () -> Unit ) { Row( @@ -431,7 +389,7 @@ private fun SingleSideUploadRow( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(id = uploadInfo.descriptionRes), + text = stringResource(id = if (isFront) R.string.stripe_front_of_id else R.string.stripe_back_of_id), modifier = Modifier.align(CenterVertically) ) when (uploadUiState) { @@ -442,6 +400,7 @@ private fun SingleSideUploadRow( Text(text = stringResource(id = R.string.stripe_select).uppercase()) } } + DocumentUploadUIState.Loading -> { CircularProgressIndicator( modifier = Modifier @@ -450,10 +409,13 @@ private fun SingleSideUploadRow( strokeWidth = 3.dp ) } + DocumentUploadUIState.Done -> { Image( painter = painterResource(id = R.drawable.stripe_check_mark), - contentDescription = stringResource(id = uploadInfo.checkmarkContentDescriptionRes), + contentDescription = stringResource( + id = if (isFront) R.string.stripe_front_of_id_selected else R.string.stripe_back_of_id_selected + ), modifier = Modifier .height(18.dp) .width(18.dp) diff --git a/identity/src/main/java/com/stripe/android/identity/utils/IdentityImageHandler.kt b/identity/src/main/java/com/stripe/android/identity/utils/IdentityImageHandler.kt index 80e6ba542aa..53969fee15d 100644 --- a/identity/src/main/java/com/stripe/android/identity/utils/IdentityImageHandler.kt +++ b/identity/src/main/java/com/stripe/android/identity/utils/IdentityImageHandler.kt @@ -4,7 +4,6 @@ import android.content.Context import android.net.Uri import androidx.activity.result.ActivityResultCaller import androidx.lifecycle.SavedStateHandle -import com.stripe.android.identity.states.IdentityScanState import javax.inject.Inject /** @@ -18,53 +17,30 @@ internal class IdentityImageHandler @Inject constructor( private lateinit var frontImageChooser: ImageChooser private lateinit var backImageChooser: ImageChooser - // [ScanType] for front and back, might be updated when current screen changes. - private var frontScanType: IdentityScanState.ScanType? = null - private var backScanType: IdentityScanState.ScanType? = null - fun registerActivityResultCaller( activityResultCaller: ActivityResultCaller, savedStateHandle: SavedStateHandle, - onFrontPhotoTaken: (Uri, IdentityScanState.ScanType?) -> Unit, - onBackPhotoTaken: (Uri, IdentityScanState.ScanType?) -> Unit, - onFrontImageChosen: (Uri, IdentityScanState.ScanType?) -> Unit, - onBackImageChosen: (Uri, IdentityScanState.ScanType?) -> Unit + onFrontPhotoTaken: (Uri) -> Unit, + onBackPhotoTaken: (Uri) -> Unit, + onFrontImageChosen: (Uri) -> Unit, + onBackImageChosen: (Uri) -> Unit ) { - frontScanType = savedStateHandle.get(FRONT_SCAN_TYPE) - backScanType = savedStateHandle.get(BACK_SCAN_TYPE) - frontPhotoTaker = PhotoTaker( activityResultCaller, identityIO, - { - onFrontPhotoTaken(it, frontScanType) - }, + onFrontPhotoTaken, savedStateHandle, FRONT_PHOTO_URI ) backPhotoTaker = PhotoTaker( activityResultCaller, identityIO, - { - onBackPhotoTaken(it, backScanType) - }, + onBackPhotoTaken, savedStateHandle, BACK_PHOTO_URI ) - frontImageChooser = ImageChooser(activityResultCaller) { - onFrontImageChosen(it, frontScanType) - } - backImageChooser = ImageChooser(activityResultCaller) { - onBackImageChosen(it, backScanType) - } - } - - fun updateScanTypes( - frontScanType: IdentityScanState.ScanType, - backScanType: IdentityScanState.ScanType? - ) { - this.frontScanType = frontScanType - this.backScanType = backScanType + frontImageChooser = ImageChooser(activityResultCaller, onFrontImageChosen) + backImageChooser = ImageChooser(activityResultCaller, onBackImageChosen) } /** @@ -102,7 +78,5 @@ internal class IdentityImageHandler @Inject constructor( companion object { const val FRONT_PHOTO_URI = "front_photo_uri" const val BACK_PHOTO_URI = "back_photo_uri" - const val FRONT_SCAN_TYPE = "front_scan_type" - const val BACK_SCAN_TYPE = "back_scan_type" } } diff --git a/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityScanViewModel.kt b/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityScanViewModel.kt index e2a8fa8dd37..bfbd36ea30d 100644 --- a/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityScanViewModel.kt +++ b/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityScanViewModel.kt @@ -8,10 +8,13 @@ import androidx.lifecycle.viewModelScope import com.stripe.android.camera.scanui.util.asRect import com.stripe.android.core.injection.UIContext import com.stripe.android.identity.analytics.ModelPerformanceTracker +import com.stripe.android.identity.camera.DocumentScanCameraManager +import com.stripe.android.identity.camera.IdentityAggregator import com.stripe.android.identity.camera.IdentityCameraManager import com.stripe.android.identity.states.IdentityScanState import com.stripe.android.identity.states.LaplacianBlurDetector import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import java.lang.ref.WeakReference import javax.inject.Inject @@ -24,6 +27,22 @@ internal class IdentityScanViewModel( @UIContext private val uiContext: CoroutineContext ) : CameraViewModel(modelPerformanceTracker, laplacianBlurDetector, uiContext) { + internal sealed class State { + object Initial : State() + object Initializing : State() + object Initialized : State() + object Scanning : State() + object Scanned : State() + } + + override suspend fun onResult(result: IdentityAggregator.FinalResult) { + super.onResult(result) + _scannerInitializedState.update { State.Scanned } + } + + private val _scannerInitializedState: MutableStateFlow = MutableStateFlow(State.Initial) + + val scannerState: StateFlow = _scannerInitializedState /** * StateFlow to keep track of current target scan type. @@ -40,6 +59,7 @@ internal class IdentityScanViewModel( scanType: IdentityScanState.ScanType, lifecycleOwner: LifecycleOwner ) { + _scannerInitializedState.update { State.Scanning } targetScanTypeFlow.update { scanType } cameraManager.requireCameraAdapter().bindToLifecycle(lifecycleOwner) scanState = null @@ -60,6 +80,25 @@ internal class IdentityScanViewModel( cameraManager.requireCameraAdapter().unbindFromLifecycle(lifecycleOwner) } + /** + * Initialize scanner, including [identityScanFlow] and [cameraManager]. + */ + fun initializeScanFlowAndUpdateState( + pageAndModelFiles: IdentityViewModel.PageAndModelFiles, + cameraManager: DocumentScanCameraManager + ) { + _scannerInitializedState.update { State.Initializing } + initializeScanFlow( + pageAndModelFiles.page, + idDetectorModelFile = pageAndModelFiles.idDetectorFile, + faceDetectorModelFile = pageAndModelFiles.faceDetectorFile + ) + this.cameraManager = cameraManager + _scannerInitializedState.update { + State.Initialized + } + } + internal class IdentityScanViewModelFactory @Inject constructor( private val context: Context, private val modelPerformanceTracker: ModelPerformanceTracker, diff --git a/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityViewModel.kt b/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityViewModel.kt index dd1ec8dcbf9..19a8a8bdccf 100644 --- a/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityViewModel.kt +++ b/identity/src/main/java/com/stripe/android/identity/viewmodel/IdentityViewModel.kt @@ -33,12 +33,15 @@ import com.stripe.android.identity.analytics.ScreenTracker import com.stripe.android.identity.camera.IdentityAggregator import com.stripe.android.identity.injection.IdentityActivitySubcomponent import com.stripe.android.identity.ml.BoundingBox +import com.stripe.android.identity.ml.Category import com.stripe.android.identity.ml.FaceDetectorAnalyzer import com.stripe.android.identity.ml.FaceDetectorOutput import com.stripe.android.identity.ml.IDDetectorOutput import com.stripe.android.identity.navigation.CameraPermissionDeniedDestination import com.stripe.android.identity.navigation.ConfirmationDestination import com.stripe.android.identity.navigation.DocSelectionDestination +import com.stripe.android.identity.navigation.DocumentScanDestination +import com.stripe.android.identity.navigation.DocumentUploadDestination import com.stripe.android.identity.navigation.ErrorDestination import com.stripe.android.identity.navigation.IdentityTopLevelDestination import com.stripe.android.identity.navigation.IndividualDestination @@ -64,8 +67,6 @@ import com.stripe.android.identity.networking.models.CollectedDataParam import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.clearData import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.collectedRequirements import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.mergeWith -import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.toScanDestination -import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.toUploadDestination import com.stripe.android.identity.networking.models.DocumentUploadParam import com.stripe.android.identity.networking.models.DocumentUploadParam.UploadMethod import com.stripe.android.identity.networking.models.Requirement @@ -374,14 +375,6 @@ internal class IdentityViewModel constructor( */ val errorCause = MutableLiveData() - /** - * Reset document uploaded state to loading state. - */ - internal fun resetDocumentUploadedState() { - _documentFrontUploadedState.updateStateAndSave { SingleSideDocumentUploadState() } - _documentBackUploadedState.updateStateAndSave { SingleSideDocumentUploadState() } - } - /** * Reset selfie uploaded state to loading state. */ @@ -427,8 +420,7 @@ internal class IdentityViewModel constructor( */ internal fun uploadScanResult( result: IdentityAggregator.FinalResult, - verificationPage: VerificationPage, - targetScanType: IdentityScanState.ScanType? + verificationPage: VerificationPage ) { when (result.result) { is IDDetectorOutput -> { @@ -436,16 +428,28 @@ internal class IdentityViewModel constructor( val boundingBox = result.result.boundingBox val scores = result.result.allScores - val isFront = when (targetScanType) { - IdentityScanState.ScanType.ID_FRONT -> true - IdentityScanState.ScanType.ID_BACK -> false - IdentityScanState.ScanType.DL_FRONT -> true - IdentityScanState.ScanType.DL_BACK -> false - // passport is always uploaded as front - IdentityScanState.ScanType.PASSPORT -> true + val isFront: Boolean + val targetScanType: IdentityScanState.ScanType + + when (result.result.category) { + Category.PASSPORT -> { + isFront = true + targetScanType = IdentityScanState.ScanType.DOC_FRONT + } + + Category.ID_FRONT -> { + isFront = true + targetScanType = IdentityScanState.ScanType.DOC_FRONT + } + + Category.ID_BACK -> { + isFront = false + targetScanType = IdentityScanState.ScanType.DOC_BACK + } + else -> { - Log.e(TAG, "incorrect targetScanType: $targetScanType") - throw IllegalStateException("incorrect targetScanType: $targetScanType") + Log.e(TAG, "incorrect category: ${result.result.category}") + throw IllegalStateException("incorrect targetScanType: ${result.result.category}") } } // upload high res @@ -1308,41 +1312,20 @@ internal class IdentityViewModel constructor( fun updateNewScanType(scanType: IdentityScanState.ScanType) { updateAnalyticsState { oldState -> when (scanType) { - IdentityScanState.ScanType.ID_FRONT -> { + IdentityScanState.ScanType.DOC_FRONT -> { oldState.copy( docFrontRetryTimes = oldState.docFrontRetryTimes?.let { it + 1 } ?: 1 ) } - IdentityScanState.ScanType.ID_BACK -> { + IdentityScanState.ScanType.DOC_BACK -> { oldState.copy( docBackRetryTimes = oldState.docBackRetryTimes?.let { it + 1 } ?: 1 ) } - IdentityScanState.ScanType.DL_FRONT -> { - oldState.copy( - docFrontRetryTimes = - oldState.docFrontRetryTimes?.let { it + 1 } ?: 1 - ) - } - - IdentityScanState.ScanType.DL_BACK -> { - oldState.copy( - docBackRetryTimes = - oldState.docBackRetryTimes?.let { it + 1 } ?: 1 - ) - } - - IdentityScanState.ScanType.PASSPORT -> { - oldState.copy( - docFrontRetryTimes = - oldState.docFrontRetryTimes?.let { it + 1 } ?: 1 - ) - } - IdentityScanState.ScanType.SELFIE -> { oldState.copy( selfieRetryTimes = @@ -1367,16 +1350,14 @@ internal class IdentityViewModel constructor( cameraPermissionEnsureable.ensureCameraPermission( onCameraReady = { sendAnalyticsRequest( - identityAnalyticsRequestFactory.cameraPermissionGranted( - type.toAnalyticsScanType() - ) + identityAnalyticsRequestFactory.cameraPermissionGranted() ) _cameraPermissionGranted.update { true } idDetectorModelFile.observe(viewLifecycleOwner) { modelResource -> when (modelResource.status) { // model ready, camera permission is granted -> navigate to scan Status.SUCCESS -> { - navController.navigateTo(type.toScanDestination()) + navController.navigateTo(DocumentScanDestination) } // model not ready, camera permission is granted -> navigate to manual capture Status.ERROR -> { @@ -1396,7 +1377,7 @@ internal class IdentityViewModel constructor( ) } else { navController.navigateTo( - type.toUploadDestination() + DocumentUploadDestination ) } } @@ -1536,22 +1517,15 @@ internal class IdentityViewModel constructor( val destinationWhenMissingBack = when (failedUploadMethod) { UploadMethod.AUTOCAPTURE -> { - failedDocumentType.toScanDestination( - shouldStartFromBack = true, - shouldPopUpToDocSelection = true - ) + DocumentScanDestination } UploadMethod.FILEUPLOAD -> { - failedDocumentType.toUploadDestination( - shouldPopUpToDocSelection = true - ) + DocumentUploadDestination } UploadMethod.MANUALCAPTURE -> { - failedDocumentType.toUploadDestination( - shouldPopUpToDocSelection = true - ) + DocumentUploadDestination } } return collectedDataParamWithForceConfirm to destinationWhenMissingBack @@ -1592,8 +1566,6 @@ internal class IdentityViewModel constructor( suspend fun collectDataForDocumentScanScreen( navController: NavController, isFront: Boolean, - collectedDataParamType: CollectedDataParam.Type, - route: String, onMissingBack: () -> Unit ) { if (isFront) { @@ -1607,17 +1579,16 @@ internal class IdentityViewModel constructor( getApplication() ) } else if (uploadedState.isUploaded()) { + val route = DocumentScanDestination.ROUTE.route postVerificationPageDataAndMaybeNavigate( navController = navController, collectedDataParam = if (isFront) { CollectedDataParam.createFromFrontUploadedResultsForAutoCapture( - type = collectedDataParamType, frontHighResResult = requireNotNull(uploadedState.highResResult.data), frontLowResResult = requireNotNull(uploadedState.lowResResult.data) ) } else { CollectedDataParam.createFromBackUploadedResultsForAutoCapture( - type = collectedDataParamType, backHighResResult = requireNotNull(uploadedState.highResResult.data), backLowResResult = requireNotNull(uploadedState.lowResResult.data) ) @@ -1641,8 +1612,6 @@ internal class IdentityViewModel constructor( */ suspend fun collectDataForDocumentUploadScreen( navController: NavController, - collectedDataParamType: CollectedDataParam.Type, - route: String, isFront: Boolean ) { if (isFront) { @@ -1661,10 +1630,9 @@ internal class IdentityViewModel constructor( "front uploaded file id is null" }, uploadMethod = requireNotNull(front.uploadMethod) - ), - idDocumentType = collectedDataParamType + ) ), - fromRoute = route + fromRoute = DocumentUploadDestination.ROUTE.route ) } } @@ -1685,10 +1653,9 @@ internal class IdentityViewModel constructor( "back uploaded file id is null" }, uploadMethod = requireNotNull(back.uploadMethod) - ), - idDocumentType = collectedDataParamType + ) ), - fromRoute = route + fromRoute = DocumentUploadDestination.ROUTE.route ) } } @@ -1766,40 +1733,40 @@ internal class IdentityViewModel constructor( imageHandler.registerActivityResultCaller( activityResultCaller, savedStateHandle, - onFrontPhotoTaken = { uri, scanType -> + onFrontPhotoTaken = { uri -> uploadManualResult( uri = uri, isFront = true, docCapturePage = requireNotNull(verificationPage.value?.data).documentCapture, uploadMethod = UploadMethod.MANUALCAPTURE, - scanType = requireNotNull(scanType) + scanType = IdentityScanState.ScanType.DOC_FRONT ) }, - onBackPhotoTaken = { uri, scanType -> + onBackPhotoTaken = { uri -> uploadManualResult( uri = uri, isFront = false, docCapturePage = requireNotNull(verificationPage.value?.data).documentCapture, uploadMethod = UploadMethod.MANUALCAPTURE, - scanType = requireNotNull(scanType) + scanType = IdentityScanState.ScanType.DOC_BACK ) }, - onFrontImageChosen = { uri, scanType -> + onFrontImageChosen = { uri -> uploadManualResult( uri = uri, isFront = true, docCapturePage = requireNotNull(verificationPage.value?.data).documentCapture, uploadMethod = UploadMethod.FILEUPLOAD, - scanType = requireNotNull(scanType) + scanType = IdentityScanState.ScanType.DOC_FRONT ) }, - onBackImageChosen = { uri, scanType -> + onBackImageChosen = { uri -> uploadManualResult( uri = uri, isFront = false, docCapturePage = requireNotNull(verificationPage.value?.data).documentCapture, uploadMethod = UploadMethod.FILEUPLOAD, - scanType = requireNotNull(scanType) + scanType = IdentityScanState.ScanType.DOC_BACK ) } ) @@ -1809,19 +1776,10 @@ internal class IdentityViewModel constructor( _visitedIndividualWelcomeScreen.updateStateAndSave { true } } - fun updateImageHandlerScanTypes( - frontScanType: IdentityScanState.ScanType, - backScanType: IdentityScanState.ScanType? - ) { - savedStateHandle[IdentityImageHandler.FRONT_SCAN_TYPE] = frontScanType - savedStateHandle[IdentityImageHandler.BACK_SCAN_TYPE] = backScanType - imageHandler.updateScanTypes(frontScanType, backScanType) - } - private fun CollectedDataParam.Type.toAnalyticsScanType() = when (this) { - CollectedDataParam.Type.DRIVINGLICENSE -> IdentityScanState.ScanType.DL_FRONT - CollectedDataParam.Type.IDCARD -> IdentityScanState.ScanType.ID_FRONT - CollectedDataParam.Type.PASSPORT -> IdentityScanState.ScanType.PASSPORT + CollectedDataParam.Type.DRIVINGLICENSE -> IdentityScanState.ScanType.DOC_FRONT + CollectedDataParam.Type.IDCARD -> IdentityScanState.ScanType.DOC_FRONT + CollectedDataParam.Type.PASSPORT -> IdentityScanState.ScanType.DOC_FRONT else -> throw IllegalStateException("Invalid CollectedDataParam.Type") } diff --git a/identity/src/test/java/com/stripe/android/identity/states/IDDetectorTransitionerTest.kt b/identity/src/test/java/com/stripe/android/identity/states/IDDetectorTransitionerTest.kt index 78a94dc19cd..723602b71bd 100644 --- a/identity/src/test/java/com/stripe/android/identity/states/IDDetectorTransitionerTest.kt +++ b/identity/src/test/java/com/stripe/android/identity/states/IDDetectorTransitionerTest.kt @@ -37,7 +37,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockNeverTimeoutClockMark val foundState = IdentityScanState.Found( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, mockReachedStateAt ) @@ -64,7 +64,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockNeverTimeoutClockMark val foundState = IdentityScanState.Found( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, mockReachedStateAt ) @@ -94,7 +94,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockNeverTimeoutClockMark val mockFoundState = mock().also { - whenever(it.type).thenReturn(ScanType.ID_FRONT) + whenever(it.type).thenReturn(ScanType.DOC_FRONT) whenever(it.reachedStateAt).thenReturn(mockReachedStateAt) whenever(it.transitioner).thenReturn(transitioner) } @@ -121,7 +121,7 @@ internal class IDDetectorTransitionerTest { assertThat(resultState).isInstanceOf(IdentityScanState.Satisfied::class.java) assertThat((resultState as IdentityScanState.Satisfied).type).isEqualTo( - ScanType.ID_FRONT + ScanType.DOC_FRONT ) } @@ -142,7 +142,7 @@ internal class IDDetectorTransitionerTest { whenever(mockReachedStateAt.elapsedSince()).thenReturn((timeRequired - 10).milliseconds) val foundState = IdentityScanState.Found( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, mockReachedStateAt ) @@ -186,7 +186,7 @@ internal class IDDetectorTransitionerTest { // never meets required time whenever(mockReachedStateAt.elapsedSince()).thenReturn((timeRequired - 10).milliseconds) val mockFoundState = mock().also { - whenever(it.type).thenReturn(ScanType.ID_FRONT) + whenever(it.type).thenReturn(ScanType.DOC_FRONT) whenever(it.reachedStateAt).thenReturn(mockReachedStateAt) whenever(it.transitioner).thenReturn(transitioner) } @@ -222,9 +222,9 @@ internal class IDDetectorTransitionerTest { assertThat(resultState).isInstanceOf(IdentityScanState.Unsatisfied::class.java) assertThat((resultState as IdentityScanState.Unsatisfied).reason).isEqualTo( - "Type ${Category.ID_BACK} doesn't match ${ScanType.ID_FRONT}" + "Type ${Category.ID_BACK} doesn't match ${ScanType.DOC_FRONT}" ) - assertThat(resultState.type).isEqualTo(ScanType.ID_FRONT) + assertThat(resultState.type).isEqualTo(ScanType.DOC_FRONT) } @Test @@ -243,7 +243,7 @@ internal class IDDetectorTransitionerTest { // never meets required time whenever(mockReachedStateAt.elapsedSince()).thenReturn((timeRequired - 10).milliseconds) val mockFoundState = mock().also { - whenever(it.type).thenReturn(ScanType.ID_FRONT) + whenever(it.type).thenReturn(ScanType.DOC_FRONT) whenever(it.reachedStateAt).thenReturn(mockReachedStateAt) whenever(it.transitioner).thenReturn(transitioner) } @@ -280,7 +280,7 @@ internal class IDDetectorTransitionerTest { ) assertThat(resultState).isInstanceOf(IdentityScanState.Satisfied::class.java) - assertThat(resultState.type).isEqualTo(ScanType.ID_FRONT) + assertThat(resultState.type).isEqualTo(ScanType.DOC_FRONT) } @Test @@ -292,7 +292,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockAlwaysTimeoutClockMark val initialState = IdentityScanState.Initial( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner ) @@ -314,7 +314,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockNeverTimeoutClockMark val initialState = IdentityScanState.Initial( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner ) @@ -336,7 +336,7 @@ internal class IDDetectorTransitionerTest { transitioner.timeoutAt = mockNeverTimeoutClockMark val initialState = IdentityScanState.Initial( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner ) @@ -365,7 +365,7 @@ internal class IDDetectorTransitionerTest { assertThat( transitioner.transitionFromSatisfied( IdentityScanState.Satisfied( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, reachedStateAt = mockReachAtClockMark ), @@ -390,7 +390,7 @@ internal class IDDetectorTransitionerTest { val satisfiedState = IdentityScanState.Satisfied( - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, reachedStateAt = mockReachAtClockMark ) @@ -413,7 +413,7 @@ internal class IDDetectorTransitionerTest { transitioner.transitionFromUnsatisfied( IdentityScanState.Unsatisfied( "reason", - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner ), mock(), @@ -438,7 +438,7 @@ internal class IDDetectorTransitionerTest { val unsatisfiedState = IdentityScanState.Unsatisfied( "reason", - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitioner, reachedStateAt = mockReachAtClockMark ) @@ -471,7 +471,7 @@ internal class IDDetectorTransitionerTest { val unsatisfiedState = IdentityScanState.Unsatisfied( "reason", - ScanType.ID_FRONT, + ScanType.DOC_FRONT, transitionerSpy, reachedStateAt = mockReachAtClockMark ) diff --git a/identity/src/test/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectTest.kt b/identity/src/test/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectTest.kt index a907a391e58..a21a9315af6 100644 --- a/identity/src/test/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectTest.kt +++ b/identity/src/test/java/com/stripe/android/identity/ui/CameraScreenLaunchedEffectTest.kt @@ -188,7 +188,7 @@ class CameraScreenLaunchedEffectTest { @Test fun verifyIDDetectorFrontFinishedResult() { - targetScanFlow.update { IdentityScanState.ScanType.ID_FRONT } + targetScanFlow.update { IdentityScanState.ScanType.DOC_FRONT } finalResult.postValue( IdentityAggregator.FinalResult( frame = mock(), @@ -200,7 +200,7 @@ class CameraScreenLaunchedEffectTest { blurScore = ID_FRONT_BLUR_SCORE ), identityState = IdentityScanState.Finished( - type = IdentityScanState.ScanType.ID_FRONT, + type = IdentityScanState.ScanType.DOC_FRONT, transitioner = mock() ) ) @@ -225,7 +225,7 @@ class CameraScreenLaunchedEffectTest { @Test fun verifyIDDetectorFrontTimeOutResult() { - targetScanFlow.update { IdentityScanState.ScanType.ID_FRONT } + targetScanFlow.update { IdentityScanState.ScanType.DOC_FRONT } finalResult.postValue( IdentityAggregator.FinalResult( frame = mock(), @@ -237,7 +237,7 @@ class CameraScreenLaunchedEffectTest { blurScore = ID_FRONT_BLUR_SCORE ), identityState = IdentityScanState.TimeOut( - type = IdentityScanState.ScanType.ID_FRONT, + type = IdentityScanState.ScanType.DOC_FRONT, transitioner = mock() ) ) @@ -251,7 +251,7 @@ class CameraScreenLaunchedEffectTest { } verify(mockIdentityAnalyticsRequestFactory).documentTimeout( - eq(IdentityScanState.ScanType.ID_FRONT) + eq(IdentityScanState.ScanType.DOC_FRONT) ) verify(mockNavController).navigate( argWhere { @@ -264,7 +264,7 @@ class CameraScreenLaunchedEffectTest { @Test fun verifyIDDetectorBackFinishedResult() { - targetScanFlow.update { IdentityScanState.ScanType.ID_BACK } + targetScanFlow.update { IdentityScanState.ScanType.DOC_BACK } finalResult.postValue( IdentityAggregator.FinalResult( frame = mock(), @@ -276,7 +276,7 @@ class CameraScreenLaunchedEffectTest { blurScore = ID_BACK_BLUR_SCORE ), identityState = IdentityScanState.Finished( - type = IdentityScanState.ScanType.ID_BACK, + type = IdentityScanState.ScanType.DOC_BACK, transitioner = mock() ) ) @@ -301,7 +301,7 @@ class CameraScreenLaunchedEffectTest { @Test fun verifyIDDetectorBackTimeOutResult() { - targetScanFlow.update { IdentityScanState.ScanType.ID_BACK } + targetScanFlow.update { IdentityScanState.ScanType.DOC_BACK } finalResult.postValue( IdentityAggregator.FinalResult( frame = mock(), @@ -313,7 +313,7 @@ class CameraScreenLaunchedEffectTest { blurScore = 1.0f ), identityState = IdentityScanState.TimeOut( - type = IdentityScanState.ScanType.ID_BACK, + type = IdentityScanState.ScanType.DOC_BACK, transitioner = mock() ) ) @@ -327,7 +327,7 @@ class CameraScreenLaunchedEffectTest { } verify(mockIdentityAnalyticsRequestFactory).documentTimeout( - eq(IdentityScanState.ScanType.ID_BACK) + eq(IdentityScanState.ScanType.DOC_BACK) ) verify(mockNavController).navigate( argWhere { diff --git a/identity/src/test/java/com/stripe/android/identity/ui/DocumentScanScreenTest.kt b/identity/src/test/java/com/stripe/android/identity/ui/DocumentScanScreenTest.kt index a7cf1a44b26..b39a48c57fd 100644 --- a/identity/src/test/java/com/stripe/android/identity/ui/DocumentScanScreenTest.kt +++ b/identity/src/test/java/com/stripe/android/identity/ui/DocumentScanScreenTest.kt @@ -10,15 +10,13 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onChildAt import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.MediatorLiveData import androidx.navigation.NavController import androidx.test.core.app.ApplicationProvider import com.stripe.android.identity.R import com.stripe.android.identity.TestApplication -import com.stripe.android.identity.navigation.IDScanDestination import com.stripe.android.identity.networking.Resource import com.stripe.android.identity.networking.models.CollectedDataParam -import com.stripe.android.identity.networking.models.VerificationPage import com.stripe.android.identity.states.IdentityScanState import com.stripe.android.identity.viewmodel.IdentityScanViewModel import com.stripe.android.identity.viewmodel.IdentityViewModel @@ -34,7 +32,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -49,30 +46,43 @@ class DocumentScanScreenTest { private val context = ApplicationProvider.getApplicationContext() private val mockNavController = mock() - private val verificationPageLiveData = - MutableLiveData(Resource.success(mock())) private val targetScanTypeFlow = MutableStateFlow(null) private val displayStateChangedFlow = MutableStateFlow?>(null) - + private val scannerStateFlow = MutableStateFlow(IdentityScanViewModel.State.Initial) + private val collectedDataFlow = MutableStateFlow(CollectedDataParam()) private val mockIdentityViewModel = mock { - on { verificationPage } doReturn verificationPageLiveData - on { pageAndModelFiles } doReturn mock() on { identityAnalyticsRequestFactory } doReturn mock() on { workContext } doReturn UnconfinedTestDispatcher() on { screenTracker } doReturn mock() + on { fpsTracker } doReturn mock() + on { pageAndModelFiles } doReturn MediatorLiveData>( + Resource.success( + IdentityViewModel.PageAndModelFiles(mock(), mock(), null) + ) + ) + on { collectedData } doReturn collectedDataFlow } private val mockIdentityScanViewModel = mock { on { targetScanTypeFlow } doReturn targetScanTypeFlow on { displayStateChangedFlow } doReturn displayStateChangedFlow on { interimResults } doReturn mock() on { finalResult } doReturn mock() + on { scannerState } doReturn scannerStateFlow + } + + @Test + fun verifyLoading() { + testDocumentScanScreen( + scannerState = IdentityScanViewModel.State.Initial + ) { + onNodeWithTag(LOADING_SCREEN_TAG).assertExists() + } } @Test fun verifyNullState() { testDocumentScanScreen { - verify(mockIdentityViewModel, times(0)).resetDocumentUploadedState() onNodeWithTag(SCAN_TITLE_TAG).assertTextEquals(context.getString(R.string.stripe_front_of_id)) onNodeWithTag(SCAN_MESSAGE_TAG).assertTextEquals(context.getString(R.string.stripe_position_id_front)) onNodeWithTag(CHECK_MARK_TAG).assertDoesNotExist() @@ -83,7 +93,6 @@ class DocumentScanScreenTest { @Test fun verifyNullStateWithShouldStartFromBack() { testDocumentScanScreen(shouldStartFromBack = true) { - verify(mockIdentityViewModel).resetDocumentUploadedState() onNodeWithTag(SCAN_TITLE_TAG).assertTextEquals(context.getString(R.string.stripe_front_of_id)) onNodeWithTag(SCAN_MESSAGE_TAG).assertTextEquals(context.getString(R.string.stripe_position_id_front)) onNodeWithTag(CHECK_MARK_TAG).assertDoesNotExist() @@ -95,7 +104,7 @@ class DocumentScanScreenTest { fun verifyInitialStateWithFrontType() { testDocumentScanScreen( displayState = mock(), - targetScanType = IdentityScanState.ScanType.ID_FRONT + targetScanType = IdentityScanState.ScanType.DOC_FRONT ) { onNodeWithTag(SCAN_TITLE_TAG).assertTextEquals(context.getString(R.string.stripe_front_of_id)) onNodeWithTag(SCAN_MESSAGE_TAG).assertTextEquals(context.getString(R.string.stripe_position_id_front)) @@ -108,7 +117,7 @@ class DocumentScanScreenTest { fun verifyInitialStateWithBackType() { testDocumentScanScreen( displayState = mock(), - targetScanType = IdentityScanState.ScanType.ID_BACK + targetScanType = IdentityScanState.ScanType.DOC_BACK ) { onNodeWithTag(SCAN_TITLE_TAG).assertTextEquals(context.getString(R.string.stripe_back_of_id)) onNodeWithTag(SCAN_MESSAGE_TAG).assertTextEquals(context.getString(R.string.stripe_position_id_back)) @@ -121,7 +130,8 @@ class DocumentScanScreenTest { fun verifyFinishedState() { testDocumentScanScreen( displayState = mock(), - targetScanType = IdentityScanState.ScanType.ID_FRONT + targetScanType = IdentityScanState.ScanType.DOC_FRONT, + scannerState = IdentityScanViewModel.State.Scanned ) { verify(mockIdentityScanViewModel).stopScan(any()) onNodeWithTag(SCAN_TITLE_TAG).assertTextEquals(context.getString(R.string.stripe_front_of_id)) @@ -134,8 +144,6 @@ class DocumentScanScreenTest { verify(mockIdentityViewModel).collectDataForDocumentScanScreen( eq(mockNavController), eq(true), - eq(CollectedDataParam.Type.IDCARD), - eq(IDScanDestination.ROUTE.route), any() ) } @@ -146,28 +154,28 @@ class DocumentScanScreenTest { displayState: IdentityScanState? = null, targetScanType: IdentityScanState.ScanType? = null, shouldStartFromBack: Boolean = false, + scannerState: IdentityScanViewModel.State = IdentityScanViewModel.State.Initialized, testBlock: ComposeContentTestRule.() -> Unit = {} ) { targetScanTypeFlow.update { targetScanType } displayState?.let { displayStateChangedFlow.update { displayState to mock() } } + collectedDataFlow.update { + if (shouldStartFromBack) { + CollectedDataParam( + idDocumentFront = mock() + ) + } else { + CollectedDataParam() + } + } + scannerStateFlow.update { scannerState } composeTestRule.setContent { DocumentScanScreen( navController = mockNavController, identityViewModel = mockIdentityViewModel, identityScanViewModel = mockIdentityScanViewModel, - frontScanType = IdentityScanState.ScanType.ID_FRONT, - backScanType = IdentityScanState.ScanType.ID_BACK, - shouldStartFromBack = shouldStartFromBack, - messageRes = DocumentScanMessageRes( - R.string.stripe_front_of_id, - R.string.stripe_back_of_id, - R.string.stripe_position_id_front, - R.string.stripe_position_id_back - ), - collectedDataParamType = CollectedDataParam.Type.IDCARD, - route = IDScanDestination.ROUTE.route ) } with(composeTestRule, testBlock) diff --git a/identity/src/test/java/com/stripe/android/identity/ui/UploadScreenTest.kt b/identity/src/test/java/com/stripe/android/identity/ui/UploadScreenTest.kt index 74fae40fc5b..48135c24532 100644 --- a/identity/src/test/java/com/stripe/android/identity/ui/UploadScreenTest.kt +++ b/identity/src/test/java/com/stripe/android/identity/ui/UploadScreenTest.kt @@ -10,16 +10,14 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.lifecycle.MutableLiveData import androidx.navigation.NavController -import com.stripe.android.identity.R import com.stripe.android.identity.TestApplication -import com.stripe.android.identity.navigation.IDUploadDestination +import com.stripe.android.identity.navigation.DocumentUploadDestination import com.stripe.android.identity.networking.Resource import com.stripe.android.identity.networking.SingleSideDocumentUploadState import com.stripe.android.identity.networking.models.CollectedDataParam import com.stripe.android.identity.networking.models.Requirement import com.stripe.android.identity.networking.models.VerificationPage import com.stripe.android.identity.networking.models.VerificationPageStaticContentDocumentCapturePage -import com.stripe.android.identity.states.IdentityScanState import com.stripe.android.identity.utils.IdentityImageHandler import com.stripe.android.identity.viewmodel.IdentityViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -80,7 +78,7 @@ class UploadScreenTest { @Test fun `when front is not uploaded, front upload UI is enabled and back UI is not visible`() { - testUploadScreen(hasBack = false) { + testUploadScreen { onNodeWithTag(FRONT_ROW_TAG).assertExists() onNodeWithTag(BACK_ROW_TAG).assertDoesNotExist() onNodeWithTag(UPLOAD_SCREEN_CONTINUE_BUTTON_TAG).onChildAt(0).assertIsNotEnabled() @@ -138,7 +136,7 @@ class UploadScreenTest { runBlocking { verify(mockIdentityViewModel).navigateToSelfieOrSubmit( same(mockNavController), - eq(IDUploadDestination.ROUTE.route) + eq(DocumentUploadDestination.ROUTE.route) ) } } @@ -187,32 +185,12 @@ class UploadScreenTest { } private fun testUploadScreen( - hasBack: Boolean = true, testBlock: ComposeContentTestRule.() -> Unit ) { composeTestRule.setContent { UploadScreen( navController = mockNavController, - identityViewModel = mockIdentityViewModel, - collectedDataParamType = CollectedDataParam.Type.IDCARD, - route = IDUploadDestination.ROUTE.route, - titleRes = R.string.stripe_file_upload, - contextRes = R.string.stripe_file_upload_content_dl, - frontInfo = DocumentUploadSideInfo( - descriptionRes = R.string.stripe_front_of_dl, - checkmarkContentDescriptionRes = R.string.stripe_front_of_dl_selected, - scanType = FRONT_SCAN_TYPE - ), - backInfo = - if (hasBack) { - DocumentUploadSideInfo( - descriptionRes = R.string.stripe_back_of_dl, - checkmarkContentDescriptionRes = R.string.stripe_back_of_dl_selected, - scanType = BACK_SCAN_TYPE - ) - } else { - null - } + identityViewModel = mockIdentityViewModel ) } with(composeTestRule, testBlock) @@ -224,11 +202,7 @@ class UploadScreenTest { ) { composeTestRule.setContent { UploadImageDialog( - uploadInfo = DocumentUploadSideInfo( - descriptionRes = R.string.stripe_front_of_dl, - checkmarkContentDescriptionRes = R.string.stripe_front_of_dl_selected, - scanType = FRONT_SCAN_TYPE - ), + isFront = true, shouldShowTakePhoto = true, shouldShowChoosePhoto = shouldShowChoosePhoto, onPhotoSelected = onPhotoSelected, @@ -240,8 +214,6 @@ class UploadScreenTest { } private companion object { - val FRONT_SCAN_TYPE = IdentityScanState.ScanType.ID_FRONT - val BACK_SCAN_TYPE = IdentityScanState.ScanType.ID_BACK val UPLOADED_STATE = SingleSideDocumentUploadState( highResResult = Resource.success(mock()), lowResResult = Resource.success(mock()) diff --git a/identity/src/test/java/com/stripe/android/identity/viewmodel/IdentityViewModelTest.kt b/identity/src/test/java/com/stripe/android/identity/viewmodel/IdentityViewModelTest.kt index 6941b806c37..df07370042c 100644 --- a/identity/src/test/java/com/stripe/android/identity/viewmodel/IdentityViewModelTest.kt +++ b/identity/src/test/java/com/stripe/android/identity/viewmodel/IdentityViewModelTest.kt @@ -34,11 +34,13 @@ import com.stripe.android.identity.analytics.ScreenTracker import com.stripe.android.identity.camera.IdentityAggregator import com.stripe.android.identity.ml.AnalyzerInput import com.stripe.android.identity.ml.BoundingBox +import com.stripe.android.identity.ml.Category import com.stripe.android.identity.ml.FaceDetectorOutput import com.stripe.android.identity.ml.IDDetectorOutput import com.stripe.android.identity.navigation.ConfirmationDestination import com.stripe.android.identity.navigation.ConsentDestination import com.stripe.android.identity.navigation.DocSelectionDestination +import com.stripe.android.identity.navigation.DocumentScanDestination import com.stripe.android.identity.navigation.ErrorDestination import com.stripe.android.identity.navigation.IdentityTopLevelDestination import com.stripe.android.identity.navigation.SelfieWarmupDestination @@ -49,7 +51,6 @@ import com.stripe.android.identity.networking.Resource import com.stripe.android.identity.networking.SingleSideDocumentUploadState import com.stripe.android.identity.networking.UploadedResult import com.stripe.android.identity.networking.models.CollectedDataParam -import com.stripe.android.identity.networking.models.CollectedDataParam.Companion.toScanDestination import com.stripe.android.identity.networking.models.DocumentUploadParam import com.stripe.android.identity.networking.models.Requirement import com.stripe.android.identity.networking.models.VerificationPage @@ -177,7 +178,6 @@ internal class IdentityViewModelTest { @Test fun `resetDocumentUploadedState does reset _documentUploadedState`() { - viewModel.resetDocumentUploadedState() assertThat(viewModel.documentFrontUploadedState.value).isEqualTo( SingleSideDocumentUploadState() ) @@ -221,8 +221,7 @@ internal class IdentityViewModelTest { mockUploadSuccess() viewModel.uploadScanResult( FINAL_FACE_DETECTOR_RESULT, - mockVerificationPage, - IdentityScanState.ScanType.SELFIE + mockVerificationPage ) listOf( @@ -249,8 +248,7 @@ internal class IdentityViewModelTest { mockUploadFailure() viewModel.uploadScanResult( FINAL_FACE_DETECTOR_RESULT, - mockVerificationPage, - IdentityScanState.ScanType.SELFIE + mockVerificationPage ) verify(mockIdentityAnalyticsRequestFactory, times(0)).imageUpload( anyOrNull(), @@ -322,12 +320,12 @@ internal class IdentityViewModelTest { viewModel.updateAnalyticsState { oldState -> oldState.copy( - scanType = IdentityScanState.ScanType.ID_FRONT + scanType = IdentityScanState.ScanType.DOC_FRONT ) } assertThat(viewModel.analyticsState.value.scanType).isEqualTo( - IdentityScanState.ScanType.ID_FRONT + IdentityScanState.ScanType.DOC_FRONT ) assertThat(viewModel.analyticsState.value.requireSelfie).isNull() assertThat(viewModel.analyticsState.value.docFrontUploadType).isNull() @@ -339,7 +337,7 @@ internal class IdentityViewModelTest { } assertThat(viewModel.analyticsState.value.scanType).isEqualTo( - IdentityScanState.ScanType.ID_FRONT + IdentityScanState.ScanType.DOC_FRONT ) assertThat(viewModel.analyticsState.value.requireSelfie).isEqualTo( false @@ -353,7 +351,7 @@ internal class IdentityViewModelTest { } assertThat(viewModel.analyticsState.value.scanType).isEqualTo( - IdentityScanState.ScanType.ID_FRONT + IdentityScanState.ScanType.DOC_FRONT ) assertThat(viewModel.analyticsState.value.requireSelfie).isEqualTo( false @@ -486,7 +484,7 @@ internal class IdentityViewModelTest { @Test fun `forceConfirm front - missingBack - navigate to back`() = - testForceConfirm(VERIFICATION_PAGE_DATA_MISSING_BACK) { targetDestination, failedCollectedDataParam -> + testForceConfirm(VERIFICATION_PAGE_DATA_MISSING_BACK) { failedCollectedDataParam -> // fulfilling front, should post with force confirm front viewModel.postVerificationPageDataForForceConfirm( requirementToForceConfirm = Requirement.IDDOCUMENTFRONT, @@ -508,14 +506,14 @@ internal class IdentityViewModelTest { ) verify(mockController).navigate( - eq(targetDestination.routeWithArgs), + eq(DocumentScanDestination.routeWithArgs), any Unit>() ) } @Test fun `forceConfirm back - missingSelfie - navigate to selfie warmup`() = - testForceConfirm(VERIFICATION_PAGE_DATA_MISSING_SELFIE) { _, failedCollectedDataParam -> + testForceConfirm(VERIFICATION_PAGE_DATA_MISSING_SELFIE) { failedCollectedDataParam -> // fulfilling back, should post with force confirm bcak viewModel.postVerificationPageDataForForceConfirm( requirementToForceConfirm = Requirement.IDDOCUMENTBACK, @@ -544,7 +542,7 @@ internal class IdentityViewModelTest { @Test fun `forceConfirm back - noMissing - submit`() = - testForceConfirm(CORRECT_WITH_SUBMITTED_SUCCESS_VERIFICATION_PAGE_DATA) { _, failedCollectedDataParam -> + testForceConfirm(CORRECT_WITH_SUBMITTED_SUCCESS_VERIFICATION_PAGE_DATA) { failedCollectedDataParam -> whenever( mockIdentityRepository.postVerificationPageSubmit( @@ -904,7 +902,7 @@ internal class IdentityViewModelTest { isFront, DOCUMENT_CAPTURE, DocumentUploadParam.UploadMethod.FILEUPLOAD, - IdentityScanState.ScanType.DL_FRONT + IdentityScanState.ScanType.DOC_FRONT ) verify(mockIdentityIO).resizeUriAndCreateFileToUpload( @@ -946,13 +944,12 @@ internal class IdentityViewModelTest { mockUploadSuccess() viewModel.uploadScanResult( - FINAL_ID_DETECTOR_RESULT, - mockVerificationPage, if (isFront) { - IdentityScanState.ScanType.ID_FRONT + FINAL_ID_DETECTOR_RESULT_FRONT } else { - IdentityScanState.ScanType.ID_BACK - } + FINAL_ID_DETECTOR_RESULT_BACK + }, + mockVerificationPage ) // high res upload @@ -1098,7 +1095,7 @@ internal class IdentityViewModelTest { isFront, DOCUMENT_CAPTURE, DocumentUploadParam.UploadMethod.FILEUPLOAD, - IdentityScanState.ScanType.DL_FRONT + IdentityScanState.ScanType.DOC_FRONT ) verify(mockIdentityAnalyticsRequestFactory, times(0)).imageUpload( @@ -1126,16 +1123,16 @@ internal class IdentityViewModelTest { private fun testForceConfirm( verificationPageDataResponse: VerificationPageData, - paramsCallback: suspend (IdentityTopLevelDestination, CollectedDataParam) -> Unit + paramsCallback: suspend (CollectedDataParam) -> Unit ) = runBlocking { // mock failed scanning front of driver license val failedDocumentType = CollectedDataParam.Type.DRIVINGLICENSE - // failed front, now fulfilled, target destination should be failed back - val targetDestination = failedDocumentType.toScanDestination( - shouldStartFromBack = true, - shouldPopUpToDocSelection = true - ) +// // failed front, now fulfilled, target destination should be failed back +// val targetDestination = failedDocumentType.toScanDestination( +// shouldStartFromBack = true, +// shouldPopUpToDocSelection = true +// ) val failedCollectedDataParam = CollectedDataParam( @@ -1162,7 +1159,6 @@ internal class IdentityViewModelTest { ) ).thenReturn(verificationPageDataResponse) paramsCallback( - targetDestination, failedCollectedDataParam ) } @@ -1229,7 +1225,24 @@ internal class IdentityViewModelTest { val INPUT_BITMAP = mock() val BOUNDING_BOX = mock() val ALL_SCORES = listOf(1f, 2f, 3f) - val FINAL_ID_DETECTOR_RESULT = IdentityAggregator.FinalResult( + val FINAL_ID_DETECTOR_RESULT_FRONT = IdentityAggregator.FinalResult( + frame = AnalyzerInput( + CameraPreviewImage( + INPUT_BITMAP, + mock() + ), + mock() + ), + result = IDDetectorOutput( + boundingBox = BOUNDING_BOX, + category = Category.ID_FRONT, + resultScore = 0.8f, + allScores = ALL_SCORES, + blurScore = 1.0f + ), + identityState = mock() + ) + val FINAL_ID_DETECTOR_RESULT_BACK = IdentityAggregator.FinalResult( frame = AnalyzerInput( CameraPreviewImage( INPUT_BITMAP, @@ -1239,7 +1252,7 @@ internal class IdentityViewModelTest { ), result = IDDetectorOutput( boundingBox = BOUNDING_BOX, - category = mock(), + category = Category.ID_BACK, resultScore = 0.8f, allScores = ALL_SCORES, blurScore = 1.0f