Skip to content

Commit

Permalink
[Identity] Support the 'closed' field in submit endpoint (#6958)
Browse files Browse the repository at this point in the history
  • Loading branch information
ccen-stripe committed Jul 24, 2023
1 parent 4207c78 commit 4760030
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavDestination
import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory
import com.stripe.android.identity.navigation.ConfirmationDestination
import com.stripe.android.identity.navigation.ConsentDestination
import com.stripe.android.identity.navigation.DebugDestination
import com.stripe.android.identity.navigation.ErrorDestination
import com.stripe.android.identity.navigation.InitialLoadingDestination
Expand Down Expand Up @@ -36,7 +37,8 @@ internal class IdentityOnBackPressedHandler(
return
}
if (navController.previousBackStackEntry?.destination?.route == InitialLoadingDestination.ROUTE.route ||
navController.previousBackStackEntry?.destination?.route == DebugDestination.ROUTE.route
navController.previousBackStackEntry?.destination?.route == DebugDestination.ROUTE.route ||
destination?.route == ConsentDestination.ROUTE.route
) {
finishWithCancelResult(
identityViewModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ internal data class ClearDataParam(
@SerialName("name")
val name: Boolean = false,
@SerialName("address")
val address: Boolean = false
val address: Boolean = false,
@SerialName("phone")
val phone: Boolean = false,
@SerialName("phone_otp")
val phoneOtp: Boolean = false
) {
internal companion object {
private const val CLEAR_DATA_PARAM = "clear_data"
Expand All @@ -47,7 +51,9 @@ internal data class ClearDataParam(
idNumber = requirements.contains(Requirement.IDNUMBER),
dob = requirements.contains(Requirement.DOB),
name = requirements.contains(Requirement.NAME),
address = requirements.contains(Requirement.ADDRESS)
address = requirements.contains(Requirement.ADDRESS),
phone = requirements.contains(Requirement.PHONE_NUMBER),
phoneOtp = requirements.contains(Requirement.PHONE_OTP)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.stripe.android.identity.navigation.IDUploadDestination
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
Expand Down Expand Up @@ -94,9 +95,12 @@ internal enum class Requirement {
FACE -> {
fromRoute == SelfieDestination.ROUTE.route
}
DOB, NAME, IDNUMBER, ADDRESS, PHONE_NUMBER, PHONE_OTP -> {
DOB, NAME, IDNUMBER, ADDRESS, PHONE_NUMBER -> {
fromRoute == IndividualDestination.ROUTE.route
}
PHONE_OTP -> {
fromRoute == OTPDestination.ROUTE.route
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ internal data class VerificationPageData(
val status: Status,
/* If true, the associated VerificationSession has been submitted for processing. */
@SerialName("submitted")
val submitted: Boolean
val submitted: Boolean,
/* If true, the associated VerificationSession has been is finished and can no longer be changed. */
@SerialName("closed")
val closed: Boolean,
) {
/**
* Status of the associated VerificationSession.
Expand Down Expand Up @@ -73,5 +76,14 @@ internal data class VerificationPageData(
Requirement.PHONE_NUMBER
)
)?.isNotEmpty() == true

/**
* When submitted but is not closed and there is still missing requirements, need to
* fallback.
*/
fun VerificationPageData.needsFallback() =
submitted && !closed && requirements.missings?.isEmpty() == false

fun VerificationPageData.submittedAndClosed() = submitted && closed
}
}
27 changes: 20 additions & 7 deletions identity/src/main/java/com/stripe/android/identity/ui/OTPScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
Expand Down Expand Up @@ -46,6 +48,7 @@ import com.stripe.android.identity.viewmodel.OTPViewState.RequestingError
import com.stripe.android.identity.viewmodel.OTPViewState.RequestingOTP
import com.stripe.android.identity.viewmodel.OTPViewState.SubmittingOTP
import com.stripe.android.uicore.elements.OTPElementUI
import kotlinx.coroutines.launch

/**
* Screen to collect an OTP from user's phone number or email address
Expand All @@ -66,6 +69,7 @@ internal fun OTPScreen(
val otpViewModel: OTPViewModel = viewModel(
factory = otpViewModelFactory
)

val viewState by otpViewModel.viewState.collectAsState()
val otpStaticPage = requireNotNull(verificationPage.phoneOtp)
val focusRequester = remember { FocusRequester() }
Expand Down Expand Up @@ -209,6 +213,8 @@ private fun OTPViewStateEffect(
focusRequester: FocusRequester
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()

LaunchedEffect(viewState) {
when (viewState) {
is InputtingOTP -> {
Expand Down Expand Up @@ -275,12 +281,12 @@ private fun OTPViewStateEffect(
)
},
onReadyToSubmit = {
postErrorAndNavigateToFinalErrorScreen(
identityViewModel,
navController,
context,
IllegalStateException("Sending CannotVerify receives ready to submit")
)
coroutineScope.launch {
identityViewModel.submitAndNavigate(
navController = navController,
fromRoute = OTPDestination.ROUTE.route
)
}
}
)
}
Expand All @@ -289,6 +295,13 @@ private fun OTPViewStateEffect(
else -> {} // no-op
}
}

DisposableEffect(Unit) {
viewModel.initialize()
onDispose {
viewModel.resetViewState()
}
}
}

private fun postErrorAndNavigateToFinalErrorScreen(
Expand All @@ -302,7 +315,7 @@ private fun postErrorAndNavigateToFinalErrorScreen(
}

private const val EMPTY_PHONE_NUMBER = ""
internal const val PHONE_NUMBER_PATTERN = "&phone_number&"
internal const val PHONE_NUMBER_PATTERN = "{phone_number}"
internal const val OTP_TITLE_TAG = "OtpTitleTag"
internal const val OTP_BODY_TAG = "OtpBodyTag"
internal const val OTP_ELEMENT_TAG = "OtpElementTag"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ 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
import com.stripe.android.identity.networking.models.Requirement.Companion.INDIVIDUAL_REQUIREMENT_SET
import com.stripe.android.identity.networking.models.Requirement.Companion.nextDestination
import com.stripe.android.identity.networking.models.VerificationPage
import com.stripe.android.identity.networking.models.VerificationPage.Companion.requireSelfie
import com.stripe.android.identity.networking.models.VerificationPageData
import com.stripe.android.identity.networking.models.VerificationPageData.Companion.hasError
import com.stripe.android.identity.networking.models.VerificationPageData.Companion.needsFallback
import com.stripe.android.identity.networking.models.VerificationPageData.Companion.submittedAndClosed
import com.stripe.android.identity.networking.models.VerificationPageRequirements
import com.stripe.android.identity.networking.models.VerificationPageStaticContentDocumentCapturePage
import com.stripe.android.identity.networking.models.VerificationPageStaticContentSelfieCapturePage
import com.stripe.android.identity.states.FaceDetectorTransitioner
Expand Down Expand Up @@ -872,7 +876,7 @@ internal class IdentityViewModel constructor(
ephemeralKey = verificationArgs.ephemeralKeySecret,
simulateDelay = simulateDelay
)
}.navigateToConfirmIfSubmitted(fromRoute, navController)
}.checkSubmitStatusAndNavigate(fromRoute, navController)
}

/**
Expand All @@ -889,21 +893,23 @@ internal class IdentityViewModel constructor(
ephemeralKey = verificationArgs.ephemeralKeySecret,
simulateDelay = simulateDelay
)
}.navigateToConfirmIfSubmitted(fromRoute, navController)
}.checkSubmitStatusAndNavigate(fromRoute, navController)
}

/**
* Check the [Result] of [VerificationPageData] and try to navigate to [ConfirmationDestination].
* Check the submt [Result] of [VerificationPageData] and try to navigate to [ConfirmationDestination].
*
* If Result is success, check the value of VerificationPageData
* If VerificationPageData has error, navigate to [ErrorDestination] with the error.
* If VerificationPageData is submitted, navigate to [ConfirmationDestination].
* If VerificationPageData is not submitted, navigate to [ErrorDestination].
* If VerificationPageData is submitted closed, navigate to [ConfirmationDestination].
* If VerificationPageData is not closed and it still has missings,
* document fallback happens during submit, update initial missings and navigate accordingly.
* Otherwise navigate to [ErrorDestination].
*
* If Result is failed, navigate to [ErrorDestination] with the failure information.
*
*/
private fun Result<VerificationPageData>.navigateToConfirmIfSubmitted(
private fun Result<VerificationPageData>.checkSubmitStatusAndNavigate(
fromRoute: String,
navController: NavController
) {
Expand All @@ -923,11 +929,39 @@ internal class IdentityViewModel constructor(
)
}
}

submittedVerificationPageData.submitted -> {
// After submit, missings got repopulated - fallback from phoneV to doc
// Need to reset all non-doc related data and start from doc.
submittedVerificationPageData.needsFallback() -> {
// update initialMissings
val newMissings =
requireNotNull(submittedVerificationPageData.requirements.missings)
_verificationPage.postValue(
Resource.success(
_verificationPage.value?.data?.copy(
requirements = VerificationPageRequirements(
missing = newMissings
)
)
)
)
// clear collectedData
_collectedData.updateStateAndSave {
CollectedDataParam()
}
// reset missingRequirement
_missingRequirements.updateStateAndSave {
newMissings.toSet()
}
navController.navigateTo(
newMissings.nextDestination(getApplication())
)
}
/**
* Only navigates to success when both submitted and closed are true.
*/
submittedVerificationPageData.submittedAndClosed() -> {
navController.navigateTo(ConfirmationDestination)
}

else -> {
errorCause.postValue(IllegalStateException("VerificationPage submit failed"))
navController.navigateToErrorScreenWithDefaultValues(getApplication())
Expand Down Expand Up @@ -972,7 +1006,8 @@ internal class IdentityViewModel constructor(

private val initialMissings: List<Requirement>
get() {
return _verificationPage.value?.data?.requirements?.missing ?: Requirement.values().toList()
return _verificationPage.value?.data?.requirements?.missing ?: Requirement.values()
.toList()
.also {
Log.e(
TAG,
Expand Down Expand Up @@ -1139,7 +1174,7 @@ internal class IdentityViewModel constructor(
/**
* Submit the verification and navigate based on result.
*/
private suspend fun submitAndNavigate(
internal suspend fun submitAndNavigate(
navController: NavController,
fromRoute: String
) {
Expand All @@ -1151,7 +1186,7 @@ internal class IdentityViewModel constructor(
verificationArgs.verificationSessionId,
verificationArgs.ephemeralKeySecret
)
}.navigateToConfirmIfSubmitted(fromRoute, navController)
}.checkSubmitStatusAndNavigate(fromRoute, navController)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ internal class OTPViewModel(
controller = OTPController()
)

init {
internal fun initialize() {
// transition to submittingOTP when otp is fully input
viewModelScope.launch {
otpElement.otpCompleteFlow.collectLatest {
Expand Down Expand Up @@ -123,6 +123,10 @@ internal class OTPViewModel(
}
}

internal fun resetViewState() {
_viewState.update { null }
}

private fun onRequestingCannotVerifySuccess(verificationPageData: VerificationPageData) {
_viewState.update {
OTPViewState.RequestingCannotVerifySuccess(verificationPageData)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import androidx.navigation.NavController
import androidx.navigation.NavDestination
import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory
import com.stripe.android.identity.navigation.ConfirmationDestination
import com.stripe.android.identity.navigation.ConsentDestination
import com.stripe.android.identity.navigation.ConsentDestination.CONSENT
import com.stripe.android.identity.navigation.DocSelectionDestination
import com.stripe.android.identity.navigation.ErrorDestination
import com.stripe.android.identity.navigation.ErrorDestination.Companion.ARG_SHOULD_FAIL
import com.stripe.android.identity.navigation.InitialLoadingDestination
import com.stripe.android.identity.navigation.clearDataAndNavigateUp
import com.stripe.android.identity.navigation.routeToScreenName
import com.stripe.android.identity.viewmodel.IdentityViewModel
import org.junit.Test
Expand Down Expand Up @@ -105,6 +105,30 @@ class IdentityOnBackPressedHandlerTest {
verify(mockFlowFinishable).finishWithResult(eq(IdentityVerificationSheet.VerificationFlowResult.Completed))
}

@Test
fun testBackPressOnConsentPage() {
val mockDestination = mock<NavDestination> {
on { route } doReturn ConsentDestination.ROUTE.route
}

handler.updateState(
destination = mockDestination,
args = null
)

handler.handleOnBackPressed()

verify(mockAnalyticsRequestFactory).verificationCanceled(
eq(false),
eq(CONSENT.routeToScreenName()),
anyOrNull(),
anyOrNull()
)
verify(mockFlowFinishable).finishWithResult(
eq(IdentityVerificationSheet.VerificationFlowResult.Canceled)
)
}

@Test
fun testBackPressOnErrorPageWithArgShouldFail() {
val mockDestination = mock<NavDestination> {
Expand Down Expand Up @@ -151,9 +175,7 @@ class IdentityOnBackPressedHandlerTest {
)
)
handler.handleOnBackPressed()
verify(mockNavController).clearDataAndNavigateUp(
mockIdentityViewModel
)
verify(mockNavController).navigateUp()
}

@Test
Expand All @@ -167,8 +189,6 @@ class IdentityOnBackPressedHandlerTest {
args = null
)
handler.handleOnBackPressed()
verify(mockNavController).clearDataAndNavigateUp(
mockIdentityViewModel
)
verify(mockNavController).navigateUp()
}
}
Loading

0 comments on commit 4760030

Please sign in to comment.