From c95a46a7a9f91221356e8b90207254cffc79b757 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Wed, 22 Jan 2025 15:43:22 -0500 Subject: [PATCH 01/10] Support networking relink flow --- .../domain/RepairAuthorizationSession.kt | 25 + .../AccountUpdateRequiredViewModel.kt | 26 +- .../bankauthrepair/BankAuthRepairScreen.kt | 10 +- .../bankauthrepair/BankAuthRepairViewModel.kt | 76 ++- .../features/common/SharedPartnerAuth.kt | 47 +- .../LinkAccountPickerViewModel.kt | 1 + .../features/notice/NoticeSheetViewModel.kt | 9 +- .../features/partnerauth/PartnerAuthScreen.kt | 6 +- .../partnerauth/PartnerAuthViewModel.kt | 496 ++---------------- .../partnerauth/SharedPartnerAuthState.kt | 6 +- .../partnerauth/SharedPartnerAuthViewModel.kt | 493 +++++++++++++++++ .../model/AuthorizationRepairResponse.kt | 15 + ...zationPendingNetworkingRepairRepository.kt | 4 +- .../FinancialConnectionsManifestRepository.kt | 38 ++ ...sFinancialConnectionsManifestRepository.kt | 8 + ...eFinancialConnectionsManifestRepository.kt | 8 + 16 files changed, 767 insertions(+), 501 deletions(-) create mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/domain/RepairAuthorizationSession.kt create mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt create mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/model/AuthorizationRepairResponse.kt diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/RepairAuthorizationSession.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/RepairAuthorizationSession.kt new file mode 100644 index 00000000000..6d0a3ef767b --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/RepairAuthorizationSession.kt @@ -0,0 +1,25 @@ +package com.stripe.android.financialconnections.domain + +import com.stripe.android.financialconnections.FinancialConnectionsSheet +import com.stripe.android.financialconnections.di.APPLICATION_ID +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession +import com.stripe.android.financialconnections.repository.FinancialConnectionsManifestRepository +import javax.inject.Inject +import javax.inject.Named + +internal class RepairAuthorizationSession @Inject constructor( + private val repository: FinancialConnectionsManifestRepository, + private val configuration: FinancialConnectionsSheet.Configuration, + @Named(APPLICATION_ID) private val applicationId: String, +) { + + suspend operator fun invoke( + coreAuthorization: String + ): FinancialConnectionsAuthorizationSession { + return repository.repairAuthorizationSession( + clientSecret = configuration.financialConnectionsSessionClientSecret, + coreAuthorization = coreAuthorization, + applicationId = applicationId, + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt index 5cfbc79ea80..df0cd57acb0 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt @@ -24,6 +24,7 @@ import com.stripe.android.financialconnections.presentation.Async import com.stripe.android.financialconnections.presentation.Async.Uninitialized import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel import com.stripe.android.financialconnections.repository.AccountUpdateRequiredContentRepository +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -33,6 +34,7 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( @Assisted initialState: AccountUpdateRequiredState, nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, private val updateRequiredContentRepository: AccountUpdateRequiredContentRepository, + private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, private val navigationManager: NavigationManager, private val eventTracker: FinancialConnectionsAnalyticsTracker, private val updateLocalManifest: UpdateLocalManifest, @@ -60,7 +62,11 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( val referrer = state.referrer when (val type = requireNotNull(state.payload()?.type)) { is Type.Repair -> { - handleUnsupportedRepairAction(referrer) + if (type.authorization != null) { + openBankAuthRepair(type.institution, type.authorization, referrer) + } else { + handleUnsupportedRepairAction(referrer) + } } is Type.Supportability -> { openPartnerAuth(type.institution, referrer) @@ -80,6 +86,24 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( navigationManager.tryNavigateTo(InstitutionPicker(referrer)) } + private fun openBankAuthRepair( + institution: FinancialConnectionsInstitution?, + authorization: String, + referrer: Pane, + ) { + if (institution != null) { + updateLocalManifest { + it.copy(activeInstitution = institution) + } + + pendingRepairRepository.set(authorization) + navigationManager.tryNavigateTo(Destination.BankAuthRepair(referrer)) + } else { + // Fall back to the institution picker + navigationManager.tryNavigateTo(InstitutionPicker(referrer)) + } + } + private fun openPartnerAuth( institution: FinancialConnectionsInstitution?, referrer: Pane, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt index fadb4de02d7..9babb2ddfdb 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt @@ -15,11 +15,11 @@ internal fun BankAuthRepairScreen() { SharedPartnerAuth( state = state.value, - onContinueClick = { /*TODO*/ }, - onCancelClick = { /*TODO*/ }, - onClickableTextClick = { /*TODO*/ }, - onWebAuthFlowFinished = { /*TODO*/ }, - onViewEffectLaunched = { /*TODO*/ }, + onContinueClick = viewModel::onLaunchAuthClick, + onCancelClick = viewModel::onCancelClick, + onClickableTextClick = viewModel::onClickableTextClick, + onWebAuthFlowFinished = viewModel::onWebAuthFlowFinished, + onViewEffectLaunched = viewModel::onViewEffectLaunched, inModal = false ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt index fdf6f51a744..385d902819e 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt @@ -4,28 +4,86 @@ import android.os.Parcelable import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.browser.BrowserManager +import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent +import com.stripe.android.financialconnections.domain.CancelAuthorizationSession +import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.HandleError import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults +import com.stripe.android.financialconnections.domain.PostAuthSessionEvent +import com.stripe.android.financialconnections.domain.PostAuthorizationSession +import com.stripe.android.financialconnections.domain.RepairAuthorizationSession +import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession +import com.stripe.android.financialconnections.features.notice.PresentSheet import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthViewModel import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate -import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel -import com.stripe.android.financialconnections.utils.error +import com.stripe.android.financialconnections.model.SynchronizeSessionResponse +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository +import com.stripe.android.financialconnections.utils.UriUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.parcelize.Parcelize +import javax.inject.Named internal class BankAuthRepairViewModel @AssistedInject constructor( + completeAuthorizationSession: CompleteAuthorizationSession, + createAuthorizationSession: PostAuthorizationSession, + cancelAuthorizationSession: CancelAuthorizationSession, + retrieveAuthorizationSession: RetrieveAuthorizationSession, + eventTracker: FinancialConnectionsAnalyticsTracker, + @Named(APPLICATION_ID) applicationId: String, + uriUtils: UriUtils, + postAuthSessionEvent: PostAuthSessionEvent, + getOrFetchSync: GetOrFetchSync, + browserManager: BrowserManager, + handleError: HandleError, + navigationManager: NavigationManager, + pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, + logger: Logger, + presentSheet: PresentSheet, @Assisted initialState: SharedPartnerAuthState, nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, -) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { + private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, + private val repairAuthSession: RepairAuthorizationSession, +) : SharedPartnerAuthViewModel( + completeAuthorizationSession, + createAuthorizationSession, + cancelAuthorizationSession, + retrieveAuthorizationSession, + eventTracker, + applicationId, + uriUtils, + postAuthSessionEvent, + getOrFetchSync, + browserManager, + handleError, + navigationManager, + pollAuthorizationSessionOAuthResults, + logger, + presentSheet, + initialState, + nativeAuthFlowCoordinator, +) { - override fun updateTopAppBar(state: SharedPartnerAuthState): TopAppBarStateUpdate { - return TopAppBarStateUpdate( - pane = Pane.BANK_AUTH_REPAIR, - allowBackNavigation = state.canNavigateBack, - error = state.payload.error, + override suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload { + val authorization = requireNotNull(pendingRepairRepository.get()?.coreAuthorization) + val activeInstitution = requireNotNull(sync.manifest.activeInstitution) + + val authSession = repairAuthSession(authorization) + + return Payload( + isStripeDirect = sync.manifest.isStripeDirect ?: false, + institution = activeInstitution, + authSession = authSession, ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt index b9a1d3e3590..de4d5364973 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt @@ -197,6 +197,7 @@ private fun SharedPartnerAuthBody( state.payload()?.let { LoadedContent( showInModal = inModal, + isRelinkSession = state.isRelinkSession, authenticationStatus = state.authenticationStatus, payload = it, onContinueClick = onContinueClick, @@ -210,6 +211,7 @@ private fun SharedPartnerAuthBody( @Composable private fun LoadedContent( showInModal: Boolean, + isRelinkSession: Boolean, authenticationStatus: Async, payload: SharedPartnerAuthState.Payload, onContinueClick: () -> Unit, @@ -226,6 +228,7 @@ private fun LoadedContent( // is Loading or Success (completing auth after redirect) authenticationStatus = authenticationStatus, showInModal = showInModal, + showSecondaryButton = !isRelinkSession, onContinueClick = onContinueClick, onCancelClick = onCancelClick, content = requireNotNull(payload.authSession.display?.text?.oauthPrepane), @@ -240,6 +243,7 @@ private fun LoadedContent( @Composable private fun PrePaneContent( showInModal: Boolean, + showSecondaryButton: Boolean, content: OauthPrepane, authenticationStatus: Async, onContinueClick: () -> Unit, @@ -284,6 +288,7 @@ private fun PrePaneContent( status = authenticationStatus, oAuthPrepane = content, showInModal = showInModal, + showSecondaryButton = showSecondaryButton, ) } ) @@ -358,6 +363,7 @@ private fun PrepaneFooter( status: Async, oAuthPrepane: OauthPrepane, showInModal: Boolean, + showSecondaryButton: Boolean, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -391,25 +397,28 @@ private fun PrepaneFooter( } } } - FinancialConnectionsButton( - onClick = onCancelClick, - type = Type.Secondary, - enabled = status !is Loading, - modifier = Modifier - .semantics { testTagsAsResourceId = true } - .testTag("cancel_cta") - .fillMaxWidth() - ) { - Text( - text = stringResource( - id = if (showInModal) { - R.string.stripe_prepane_cancel_cta - } else { - R.string.stripe_prepane_choose_different_bank_cta - } - ), - textAlign = TextAlign.Center - ) + + if (showSecondaryButton) { + FinancialConnectionsButton( + onClick = onCancelClick, + type = Type.Secondary, + enabled = status !is Loading, + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .testTag("cancel_cta") + .fillMaxWidth() + ) { + Text( + text = stringResource( + id = if (showInModal) { + R.string.stripe_prepane_cancel_cta + } else { + R.string.stripe_prepane_choose_different_bank_cta + } + ), + textAlign = TextAlign.Center + ) + } } } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt index 41f45475ed4..6e14ab8f427 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/linkaccountpicker/LinkAccountPickerViewModel.kt @@ -389,6 +389,7 @@ internal class LinkAccountPickerViewModel @AssistedInject constructor( generic = genericContent, type = Repair( authorization = authorization?.let { payload.partnerToCoreAuths?.getValue(it) }, + institution = institution, ), ) PARTNER_AUTH -> UpdateRequired( diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/notice/NoticeSheetViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/notice/NoticeSheetViewModel.kt index 1cd67c5eb20..02974dd2fb4 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/notice/NoticeSheetViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/notice/NoticeSheetViewModel.kt @@ -144,10 +144,15 @@ internal data class NoticeSheetState( sealed interface Type : Parcelable { @Parcelize - data class Repair(val authorization: String?) : Type + data class Repair( + val authorization: String?, + val institution: FinancialConnectionsInstitution?, + ) : Type @Parcelize - data class Supportability(val institution: FinancialConnectionsInstitution?) : Type + data class Supportability( + val institution: FinancialConnectionsInstitution?, + ) : Type } } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt index 525c84b7a28..6319f2bc45e 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt @@ -1,7 +1,7 @@ package com.stripe.android.financialconnections.features.partnerauth import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import com.stripe.android.financialconnections.features.common.SharedPartnerAuth import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.presentation.paneViewModel @@ -15,11 +15,11 @@ internal fun PartnerAuthScreen(inModal: Boolean) { args = PartnerAuthViewModel.Args(inModal, Pane.PARTNER_AUTH) ) } - val state: State = viewModel.stateFlow.collectAsState() + val state by viewModel.stateFlow.collectAsState() SharedPartnerAuth( inModal = inModal, - state = state.value, + state = state, onContinueClick = viewModel::onLaunchAuthClick, onCancelClick = viewModel::onCancelClick, onClickableTextClick = viewModel::onClickableTextClick, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt index 349b0832139..ec4e52afeba 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt @@ -1,504 +1,84 @@ package com.stripe.android.financialconnections.features.partnerauth import android.os.Parcelable -import android.webkit.URLUtil -import androidx.core.net.toUri import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.stripe.android.core.Logger -import com.stripe.android.financialconnections.FinancialConnections -import com.stripe.android.financialconnections.analytics.AuthSessionEvent -import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Launched -import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Loaded -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionOpened -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionRetrieved -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionUrlReceived -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickCancel -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickChooseAnotherBank -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickContinue import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker -import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Name -import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.browser.BrowserManager import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent import com.stripe.android.financialconnections.domain.CancelAuthorizationSession import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession import com.stripe.android.financialconnections.domain.GetOrFetchSync -import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.IfMissingActiveAuthSession import com.stripe.android.financialconnections.domain.HandleError import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults import com.stripe.android.financialconnections.domain.PostAuthSessionEvent import com.stripe.android.financialconnections.domain.PostAuthorizationSession import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession -import com.stripe.android.financialconnections.exception.FinancialConnectionsError -import com.stripe.android.financialconnections.exception.PartnerAuthError -import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException -import com.stripe.android.financialconnections.features.common.enableRetrieveAuthSession -import com.stripe.android.financialconnections.features.notice.NoticeSheetState.NoticeSheetContent.DataAccess import com.stripe.android.financialconnections.features.notice.PresentSheet -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect.OpenPartnerAuth -import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.SynchronizeSessionResponse -import com.stripe.android.financialconnections.navigation.Destination -import com.stripe.android.financialconnections.navigation.Destination.AccountPicker import com.stripe.android.financialconnections.navigation.NavigationManager -import com.stripe.android.financialconnections.navigation.PopUpToBehavior -import com.stripe.android.financialconnections.navigation.destination -import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate -import com.stripe.android.financialconnections.presentation.Async.Fail -import com.stripe.android.financialconnections.presentation.Async.Loading -import com.stripe.android.financialconnections.presentation.Async.Uninitialized -import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel -import com.stripe.android.financialconnections.presentation.WebAuthFlowState import com.stripe.android.financialconnections.utils.UriUtils -import com.stripe.android.financialconnections.utils.error import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import java.util.Date import javax.inject.Named -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus as Status internal class PartnerAuthViewModel @AssistedInject constructor( - private val completeAuthorizationSession: CompleteAuthorizationSession, - private val createAuthorizationSession: PostAuthorizationSession, - private val cancelAuthorizationSession: CancelAuthorizationSession, - private val retrieveAuthorizationSession: RetrieveAuthorizationSession, - private val eventTracker: FinancialConnectionsAnalyticsTracker, - @Named(APPLICATION_ID) private val applicationId: String, - private val uriUtils: UriUtils, - private val postAuthSessionEvent: PostAuthSessionEvent, - private val getOrFetchSync: GetOrFetchSync, - private val browserManager: BrowserManager, - private val handleError: HandleError, - private val navigationManager: NavigationManager, - private val pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, - private val logger: Logger, - private val presentSheet: PresentSheet, + completeAuthorizationSession: CompleteAuthorizationSession, + createAuthorizationSession: PostAuthorizationSession, + cancelAuthorizationSession: CancelAuthorizationSession, + retrieveAuthorizationSession: RetrieveAuthorizationSession, + eventTracker: FinancialConnectionsAnalyticsTracker, + @Named(APPLICATION_ID) applicationId: String, + uriUtils: UriUtils, + postAuthSessionEvent: PostAuthSessionEvent, + getOrFetchSync: GetOrFetchSync, + browserManager: BrowserManager, + handleError: HandleError, + navigationManager: NavigationManager, + pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, + logger: Logger, + presentSheet: PresentSheet, @Assisted initialState: SharedPartnerAuthState, nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, -) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { - - init { - handleErrors() - launchBrowserIfNonOauth() - restoreOrCreateAuthSession() - } - - override fun updateTopAppBar(state: SharedPartnerAuthState): TopAppBarStateUpdate? { - return if (state.inModal) { - null - } else { - TopAppBarStateUpdate( - pane = PANE, - allowBackNavigation = state.canNavigateBack, - error = state.payload.error, - ) - } - } - - private fun restoreOrCreateAuthSession() = suspend { - // A session should have been created in the previous pane and set as the active - // auth session in the manifest. - // if coming from a process kill, we'll fetch the current manifest from network, - // that should contain the active auth session. - val sync: SynchronizeSessionResponse = getOrFetchSync() +) : SharedPartnerAuthViewModel( + completeAuthorizationSession, + createAuthorizationSession, + cancelAuthorizationSession, + retrieveAuthorizationSession, + eventTracker, + applicationId, + uriUtils, + postAuthSessionEvent, + getOrFetchSync, + browserManager, + handleError, + navigationManager, + pollAuthorizationSessionOAuthResults, + logger, + presentSheet, + initialState, + nativeAuthFlowCoordinator, +) { + + override suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload { val manifest = sync.manifest val authSession = manifest.activeAuthSession ?: createAuthorizationSession( institution = requireNotNull(manifest.activeInstitution), sync = sync ) - Payload( + return Payload( isStripeDirect = manifest.isStripeDirect ?: false, institution = requireNotNull(manifest.activeInstitution), authSession = authSession, ) - }.execute { - copy(payload = it) - } - - private fun recreateAuthSession() = suspend { - val launchedEvent = Launched(Date()) - val sync: SynchronizeSessionResponse = getOrFetchSync() - val manifest: FinancialConnectionsSessionManifest = sync.manifest - val authSession = createAuthorizationSession( - institution = requireNotNull(manifest.activeInstitution), - sync = sync - ) - logger.debug("Created auth session ${authSession.id}") - Payload( - authSession = authSession, - institution = requireNotNull(manifest.activeInstitution), - isStripeDirect = manifest.isStripeDirect ?: false - ).also { - // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. - val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } - postAuthSessionEvent( - authSession.id, - listOfNotNull(launchedEvent, loadedEvent) - ) - } - }.execute( - // keeps existing payload to prevent showing full-screen loading. - retainValue = SharedPartnerAuthState::payload - ) { - copy(payload = it) - } - - private fun launchBrowserIfNonOauth() { - onAsync( - prop = SharedPartnerAuthState::payload, - onSuccess = { - // launch auth for non-OAuth (skip pre-pane). - if (!it.authSession.isOAuth) { - launchAuthInBrowser(it.authSession) - } - } - ) - } - - private fun handleErrors() { - onAsync( - SharedPartnerAuthState::payload, - onFail = { - handleError( - extraMessage = "Error fetching payload / posting AuthSession", - error = it, - pane = PANE, - displayErrorScreen = true - ) - }, - onSuccess = { eventTracker.track(PaneLoaded(PANE)) } - ) - onAsync( - SharedPartnerAuthState::authenticationStatus, - onFail = { - handleError( - extraMessage = "Error with authentication status", - error = if (it is FinancialConnectionsError) it else PartnerAuthError(it.message), - pane = PANE, - displayErrorScreen = true - ) - } - ) - } - - fun onLaunchAuthClick() { - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - - withState { state -> - val authSession = requireNotNull(state.payload()?.authSession) { - "Payload shouldn't be null when the user launches the auth flow" - } - - reportOAuthLaunched(authSession.id) - launchAuthInBrowser(authSession) - } - } - - private fun reportOAuthLaunched(sessionId: String) { - postAuthSessionEvent(sessionId, AuthSessionEvent.OAuthLaunched(Date())) - eventTracker.track(PrepaneClickContinue(PANE)) - } - - private fun launchAuthInBrowser(authSession: FinancialConnectionsAuthorizationSession) { - authSession.browserReadyUrl()?.let { url -> - setState { copy(viewEffect = OpenPartnerAuth(url)) } - eventTracker.track( - AuthSessionOpened( - id = authSession.id, - pane = PANE, - flow = authSession.flow, - defaultBrowser = browserManager.getPackageToHandleUri(uri = url.toUri()), - ) - ) - } - } - - /** - * Auth Session url after clearing the deep link prefix (required for non-native app2app flows). - */ - private fun FinancialConnectionsAuthorizationSession.browserReadyUrl(): String? = - url?.replaceFirst("stripe-auth://native-redirect/$applicationId/", "") - - fun onWebAuthFlowFinished( - webStatus: WebAuthFlowState - ) { - logger.debug("Web AuthFlow status received $webStatus") - viewModelScope.launch { - when (webStatus) { - is WebAuthFlowState.Canceled -> { - onAuthCancelled(webStatus.url) - } - - is WebAuthFlowState.Failed -> { - onAuthFailed(webStatus.url, webStatus.message, webStatus.reason) - } - - WebAuthFlowState.InProgress -> { - setState { - copy( - authenticationStatus = Loading(Status(Action.AUTHENTICATING)) - ) - } - } - - is WebAuthFlowState.Success -> { - completeAuthorizationSession(webStatus.url) - } - - WebAuthFlowState.Uninitialized -> {} - } - } - } - - private suspend fun onAuthFailed( - url: String, - message: String, - reason: String? - ) { - val error = WebAuthFlowFailedException(message, reason) - kotlin.runCatching { - val authSession = getOrFetchSync().manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = PANE, - url = url, - authSessionId = authSession?.id, - status = "failed" - ) - ) - eventTracker.logError( - extraMessage = "Auth failed, cancelling AuthSession", - error = error, - logger = logger, - pane = PANE - ) - when { - authSession != null -> { - postAuthSessionEvent(authSession.id, AuthSessionEvent.Failure(Date(), error)) - cancelAuthorizationSession(authSession.id) - } - - else -> logger.debug("Could not find AuthSession to cancel.") - } - setState { copy(authenticationStatus = Fail(error)) } - }.onFailure { - eventTracker.logError( - extraMessage = "failed cancelling session after failed web flow", - error = it, - logger = logger, - pane = PANE - ) - } - } - - private suspend fun onAuthCancelled(url: String?) { - kotlin.runCatching { - logger.debug("Auth cancelled, cancelling AuthSession") - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - val manifest = getOrFetchSync( - refetchCondition = IfMissingActiveAuthSession - ).manifest - val authSession = manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = PANE, - url = url ?: "none", - authSessionId = authSession?.id, - status = "cancelled" - ) - ) - requireNotNull(authSession) - if (manifest.enableRetrieveAuthSession()) { - // if the client canceled mid-flow (either by closing the browser or - // cancelling on the institution page), retrieve the auth session - // and try to recover if possible. - val retrievedAuthSession = retrieveAuthorizationSession(authSession.id) - val nextPane = retrievedAuthSession.nextPane - eventTracker.track( - AuthSessionRetrieved( - authSessionId = retrievedAuthSession.id, - nextPane = nextPane - ) - ) - if (nextPane == PANE) { - // auth session was not completed, proceed with cancellation - cancelAuthSessionAndContinue(authSession = retrievedAuthSession) - } else { - // auth session succeeded although client didn't retrieve any deeplink. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) - navigationManager.tryNavigateTo(nextPane.destination(referrer = PANE)) - } - } else { - cancelAuthSessionAndContinue(authSession) - } - }.onFailure { - eventTracker.logError( - "failed cancelling session after cancelled web flow. url: $url", - it, - logger, - PANE - ) - setState { copy(authenticationStatus = Fail(it)) } - } - } - - /** - * Cancels the given [authSession] and navigates to the next pane (non-OAuth) / retries (OAuth). - */ - private suspend fun cancelAuthSessionAndContinue( - authSession: FinancialConnectionsAuthorizationSession - ) { - val result = cancelAuthorizationSession(authSession.id) - if (authSession.isOAuth) { - // For OAuth institutions, create a new session and navigate to its nextPane (prepane). - logger.debug("Creating a new session for this OAuth institution") - // Send retry event as we're presenting the prepane again. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Retry(Date())) - // for OAuth institutions, we remain on the pre-pane, - // but create a brand new auth session - setState { copy(authenticationStatus = Uninitialized) } - recreateAuthSession() - } else { - // For non-OAuth institutions, navigate to Session cancellation's next pane. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Cancel(Date())) - navigationManager.tryNavigateTo( - route = result.nextPane.destination(referrer = PANE), - popUpTo = PopUpToBehavior.Current(inclusive = true), - ) - } - } - - private suspend fun completeAuthorizationSession(url: String) { - kotlin.runCatching { - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - val authSession = getOrFetchSync( - refetchCondition = IfMissingActiveAuthSession - ).manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = PANE, - url = url, - authSessionId = authSession?.id, - status = "success" - ) - ) - requireNotNull(authSession) - postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) - val nextPane = if (authSession.isOAuth) { - logger.debug("Web AuthFlow completed! waiting for oauth results") - val oAuthResults = pollAuthorizationSessionOAuthResults(authSession) - logger.debug("OAuth results received! completing session") - val updatedSession = completeAuthorizationSession( - authorizationSessionId = authSession.id, - publicToken = oAuthResults.publicToken - ) - logger.debug("Session authorized!") - updatedSession.nextPane.destination(referrer = PANE) - } else { - AccountPicker(referrer = PANE) - } - FinancialConnections.emitEvent(Name.INSTITUTION_AUTHORIZED) - navigationManager.tryNavigateTo(nextPane) - }.onFailure { - eventTracker.logError( - extraMessage = "failed authorizing session", - error = it, - logger = logger, - pane = PANE - ) - setState { copy(authenticationStatus = Fail(it)) } - } - } - - // if clicked uri contains an eventName query param, track click event. - fun onClickableTextClick(uri: String) = viewModelScope.launch { - uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> - eventTracker.track(Click(eventName, pane = PANE)) - } - if (URLUtil.isNetworkUrl(uri)) { - setState { - copy( - viewEffect = ViewEffect.OpenUrl( - uri, - Date().time - ) - ) - } - } else { - val managedUri = SharedPartnerAuthState.ClickableText.entries - .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } - when (managedUri) { - SharedPartnerAuthState.ClickableText.DATA -> presentDataAccessBottomSheet() - null -> logger.error("Unrecognized clickable text: $uri") - } - } - } - - private fun presentDataAccessBottomSheet() { - val authSession = stateFlow.value.payload()?.authSession - val notice = authSession?.display?.text?.consent?.dataAccessNotice ?: return - presentSheet( - content = DataAccess(notice), - referrer = PANE, - ) - } - - fun onViewEffectLaunched() { - setState { - copy(viewEffect = null) - } - } - - fun onCancelClick() = withState { state -> - if (state.inModal) { - eventTracker.track(PrepaneClickCancel(pane = PANE)) - } else { - eventTracker.track(PrepaneClickChooseAnotherBank(pane = PANE)) - } - - viewModelScope.launch { - // set loading state while cancelling the active auth session, and navigate back - setState { copy(authenticationStatus = Loading(value = Status(Action.CANCELLING))) } - runCatching { - val authSession = requireNotNull( - getOrFetchSync(refetchCondition = IfMissingActiveAuthSession).manifest.activeAuthSession - ) - cancelAuthorizationSession(authSession.id) - } - if (state.inModal) { - cancelInModal() - } else { - cancelInFullscreen() - } - } - } - - private fun cancelInModal() { - navigationManager.tryNavigateBack() - } - - private fun cancelInFullscreen() { - navigationManager.tryNavigateTo( - route = Destination.InstitutionPicker(referrer = PANE), - popUpTo = PopUpToBehavior.Current( - inclusive = true, - ), - ) } @Parcelize @@ -516,7 +96,5 @@ internal class PartnerAuthViewModel @AssistedInject constructor( parentComponent.partnerAuthViewModelFactory.create(SharedPartnerAuthState(args)) } } - - private val PANE = Pane.PARTNER_AUTH } } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt index c1f03670634..3db3782b569 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt @@ -18,6 +18,9 @@ internal data class SharedPartnerAuthState( val inModal: Boolean = false, ) { + val isRelinkSession: Boolean + get() = pane == Pane.BANK_AUTH_REPAIR + constructor(args: PartnerAuthViewModel.Args) : this( pane = args.pane, inModal = args.inModal, @@ -48,7 +51,8 @@ internal data class SharedPartnerAuthState( authenticationStatus !is Loading && authenticationStatus !is Success && // Failures posting institution -> don't allow back navigation - payload !is Fail + payload !is Fail && + !isRelinkSession sealed interface ViewEffect { data class OpenPartnerAuth( diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt new file mode 100644 index 00000000000..7a62eb2e71d --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt @@ -0,0 +1,493 @@ +package com.stripe.android.financialconnections.features.partnerauth + +import android.webkit.URLUtil +import androidx.core.net.toUri +import androidx.lifecycle.viewModelScope +import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.FinancialConnections +import com.stripe.android.financialconnections.analytics.AuthSessionEvent +import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Launched +import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Loaded +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionOpened +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionRetrieved +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionUrlReceived +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickCancel +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickChooseAnotherBank +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickContinue +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Name +import com.stripe.android.financialconnections.analytics.logError +import com.stripe.android.financialconnections.browser.BrowserManager +import com.stripe.android.financialconnections.domain.CancelAuthorizationSession +import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession +import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.IfMissingActiveAuthSession +import com.stripe.android.financialconnections.domain.HandleError +import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults +import com.stripe.android.financialconnections.domain.PostAuthSessionEvent +import com.stripe.android.financialconnections.domain.PostAuthorizationSession +import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession +import com.stripe.android.financialconnections.exception.FinancialConnectionsError +import com.stripe.android.financialconnections.exception.PartnerAuthError +import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException +import com.stripe.android.financialconnections.features.common.enableRetrieveAuthSession +import com.stripe.android.financialconnections.features.notice.NoticeSheetState.NoticeSheetContent.DataAccess +import com.stripe.android.financialconnections.features.notice.PresentSheet +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect.OpenPartnerAuth +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane +import com.stripe.android.financialconnections.model.SynchronizeSessionResponse +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.Destination.AccountPicker +import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior +import com.stripe.android.financialconnections.navigation.destination +import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate +import com.stripe.android.financialconnections.presentation.Async.Fail +import com.stripe.android.financialconnections.presentation.Async.Loading +import com.stripe.android.financialconnections.presentation.Async.Uninitialized +import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel +import com.stripe.android.financialconnections.presentation.WebAuthFlowState +import com.stripe.android.financialconnections.utils.UriUtils +import com.stripe.android.financialconnections.utils.error +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus as Status + +private const val InitializationDelayMillis = 200L + +internal abstract class SharedPartnerAuthViewModel( + private val completeAuthorizationSession: CompleteAuthorizationSession, + protected val createAuthorizationSession: PostAuthorizationSession, + private val cancelAuthorizationSession: CancelAuthorizationSession, + private val retrieveAuthorizationSession: RetrieveAuthorizationSession, + private val eventTracker: FinancialConnectionsAnalyticsTracker, + private val applicationId: String, + private val uriUtils: UriUtils, + private val postAuthSessionEvent: PostAuthSessionEvent, + private val getOrFetchSync: GetOrFetchSync, + private val browserManager: BrowserManager, + private val handleError: HandleError, + private val navigationManager: NavigationManager, + private val pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, + private val logger: Logger, + private val presentSheet: PresentSheet, + private val initialState: SharedPartnerAuthState, + nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, +) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { + + private val pane: Pane + get() = initialState.pane + + init { + handleErrors() + launchBrowserIfNonOauth() + initializeState() + } + + abstract suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload + + override fun updateTopAppBar(state: SharedPartnerAuthState): TopAppBarStateUpdate? { + return if (state.inModal) { + null + } else { + TopAppBarStateUpdate( + pane = state.pane, + allowBackNavigation = state.canNavigateBack, + error = state.payload.error, + ) + } + } + + private fun initializeState() { + suspend { + // This is a bad workaround to prevent the subclass' fetchPayload from being called + // before the subclass' constructor is called. + delay(InitializationDelayMillis) + + val sync: SynchronizeSessionResponse = getOrFetchSync() + fetchPayload(sync) + }.execute { + copy(payload = it) + } + } + + private fun recreateAuthSession() = suspend { + val launchedEvent = Launched(Date()) + val sync: SynchronizeSessionResponse = getOrFetchSync() + val manifest: FinancialConnectionsSessionManifest = sync.manifest + val authSession = createAuthorizationSession( + institution = requireNotNull(manifest.activeInstitution), + sync = sync + ) + logger.debug("Created auth session ${authSession.id}") + Payload( + authSession = authSession, + institution = requireNotNull(manifest.activeInstitution), + isStripeDirect = manifest.isStripeDirect ?: false + ).also { + // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. + val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } + postAuthSessionEvent( + authSession.id, + listOfNotNull(launchedEvent, loadedEvent) + ) + } + }.execute( + // keeps existing payload to prevent showing full-screen loading. + retainValue = SharedPartnerAuthState::payload + ) { + copy(payload = it) + } + + private fun launchBrowserIfNonOauth() { + onAsync( + prop = SharedPartnerAuthState::payload, + onSuccess = { + // launch auth for non-OAuth (skip pre-pane). + if (!it.authSession.isOAuth) { + launchAuthInBrowser(it.authSession) + } + } + ) + } + + private fun handleErrors() { + onAsync( + SharedPartnerAuthState::payload, + onFail = { + handleError( + extraMessage = "Error fetching payload / posting AuthSession", + error = it, + pane = pane, + displayErrorScreen = true + ) + }, + onSuccess = { eventTracker.track(PaneLoaded(pane)) } + ) + onAsync( + SharedPartnerAuthState::authenticationStatus, + onFail = { + handleError( + extraMessage = "Error with authentication status", + error = if (it is FinancialConnectionsError) it else PartnerAuthError(it.message), + pane = pane, + displayErrorScreen = true + ) + } + ) + } + + fun onLaunchAuthClick() { + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + + withState { state -> + val authSession = requireNotNull(state.payload()?.authSession) { + "Payload shouldn't be null when the user launches the auth flow" + } + + reportOAuthLaunched(authSession.id) + launchAuthInBrowser(authSession) + } + } + + private fun reportOAuthLaunched(sessionId: String) { + postAuthSessionEvent(sessionId, AuthSessionEvent.OAuthLaunched(Date())) + eventTracker.track(PrepaneClickContinue(pane)) + } + + private fun launchAuthInBrowser(authSession: FinancialConnectionsAuthorizationSession) { + authSession.browserReadyUrl()?.let { url -> + setState { copy(viewEffect = OpenPartnerAuth(url)) } + eventTracker.track( + AuthSessionOpened( + id = authSession.id, + pane = pane, + flow = authSession.flow, + defaultBrowser = browserManager.getPackageToHandleUri(uri = url.toUri()), + ) + ) + } + } + + /** + * Auth Session url after clearing the deep link prefix (required for non-native app2app flows). + */ + private fun FinancialConnectionsAuthorizationSession.browserReadyUrl(): String? = + url?.replaceFirst("stripe-auth://native-redirect/$applicationId/", "") + + fun onWebAuthFlowFinished( + webStatus: WebAuthFlowState + ) { + logger.debug("Web AuthFlow status received $webStatus") + viewModelScope.launch { + when (webStatus) { + is WebAuthFlowState.Canceled -> { + onAuthCancelled(webStatus.url) + } + + is WebAuthFlowState.Failed -> { + onAuthFailed(webStatus.url, webStatus.message, webStatus.reason) + } + + WebAuthFlowState.InProgress -> { + setState { + copy( + authenticationStatus = Loading(Status(Action.AUTHENTICATING)) + ) + } + } + + is WebAuthFlowState.Success -> { + completeAuthorizationSession(webStatus.url) + } + + WebAuthFlowState.Uninitialized -> {} + } + } + } + + private suspend fun onAuthFailed( + url: String, + message: String, + reason: String? + ) { + val error = WebAuthFlowFailedException(message, reason) + kotlin.runCatching { + val authSession = getOrFetchSync().manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url, + authSessionId = authSession?.id, + status = "failed" + ) + ) + eventTracker.logError( + extraMessage = "Auth failed, cancelling AuthSession", + error = error, + logger = logger, + pane = pane + ) + when { + authSession != null -> { + postAuthSessionEvent(authSession.id, AuthSessionEvent.Failure(Date(), error)) + cancelAuthorizationSession(authSession.id) + } + + else -> logger.debug("Could not find AuthSession to cancel.") + } + setState { copy(authenticationStatus = Fail(error)) } + }.onFailure { + eventTracker.logError( + extraMessage = "failed cancelling session after failed web flow", + error = it, + logger = logger, + pane = pane + ) + } + } + + private suspend fun onAuthCancelled(url: String?) { + kotlin.runCatching { + logger.debug("Auth cancelled, cancelling AuthSession") + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + val manifest = getOrFetchSync( + refetchCondition = IfMissingActiveAuthSession + ).manifest + val authSession = manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url ?: "none", + authSessionId = authSession?.id, + status = "cancelled" + ) + ) + requireNotNull(authSession) + if (manifest.enableRetrieveAuthSession()) { + // if the client canceled mid-flow (either by closing the browser or + // cancelling on the institution page), retrieve the auth session + // and try to recover if possible. + val retrievedAuthSession = retrieveAuthorizationSession(authSession.id) + val nextPane = retrievedAuthSession.nextPane + eventTracker.track( + AuthSessionRetrieved( + authSessionId = retrievedAuthSession.id, + nextPane = nextPane + ) + ) + if (nextPane == pane) { + // auth session was not completed, proceed with cancellation + cancelAuthSessionAndContinue(authSession = retrievedAuthSession) + } else { + // auth session succeeded although client didn't retrieve any deeplink. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) + navigationManager.tryNavigateTo(nextPane.destination(referrer = pane)) + } + } else { + cancelAuthSessionAndContinue(authSession) + } + }.onFailure { + eventTracker.logError( + "failed cancelling session after cancelled web flow. url: $url", + it, + logger, + pane + ) + setState { copy(authenticationStatus = Fail(it)) } + } + } + + /** + * Cancels the given [authSession] and navigates to the next pane (non-OAuth) / retries (OAuth). + */ + private suspend fun cancelAuthSessionAndContinue( + authSession: FinancialConnectionsAuthorizationSession + ) { + val result = cancelAuthorizationSession(authSession.id) + if (authSession.isOAuth) { + // For OAuth institutions, create a new session and navigate to its nextPane (prepane). + logger.debug("Creating a new session for this OAuth institution") + // Send retry event as we're presenting the prepane again. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Retry(Date())) + // for OAuth institutions, we remain on the pre-pane, + // but create a brand new auth session + setState { copy(authenticationStatus = Uninitialized) } + recreateAuthSession() + } else { + // For non-OAuth institutions, navigate to Session cancellation's next pane. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Cancel(Date())) + navigationManager.tryNavigateTo( + route = result.nextPane.destination(referrer = pane), + popUpTo = PopUpToBehavior.Current(inclusive = true), + ) + } + } + + private suspend fun completeAuthorizationSession(url: String) { + kotlin.runCatching { + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + val authSession = getOrFetchSync( + refetchCondition = IfMissingActiveAuthSession + ).manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url, + authSessionId = authSession?.id, + status = "success" + ) + ) + requireNotNull(authSession) + postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) + val nextPane = if (authSession.isOAuth) { + logger.debug("Web AuthFlow completed! waiting for oauth results") + val oAuthResults = pollAuthorizationSessionOAuthResults(authSession) + logger.debug("OAuth results received! completing session") + val updatedSession = completeAuthorizationSession( + authorizationSessionId = authSession.id, + publicToken = oAuthResults.publicToken + ) + logger.debug("Session authorized!") + updatedSession.nextPane.destination(referrer = pane) + } else { + AccountPicker(referrer = pane) + } + FinancialConnections.emitEvent(Name.INSTITUTION_AUTHORIZED) + navigationManager.tryNavigateTo(nextPane) + }.onFailure { + eventTracker.logError( + extraMessage = "failed authorizing session", + error = it, + logger = logger, + pane = pane + ) + setState { copy(authenticationStatus = Fail(it)) } + } + } + + // if clicked uri contains an eventName query param, track click event. + fun onClickableTextClick(uri: String) = viewModelScope.launch { + uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> + eventTracker.track(Click(eventName, pane = pane)) + } + if (URLUtil.isNetworkUrl(uri)) { + setState { + copy( + viewEffect = ViewEffect.OpenUrl( + uri, + Date().time + ) + ) + } + } else { + val managedUri = SharedPartnerAuthState.ClickableText.entries + .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } + when (managedUri) { + SharedPartnerAuthState.ClickableText.DATA -> presentDataAccessBottomSheet() + null -> logger.error("Unrecognized clickable text: $uri") + } + } + } + + private fun presentDataAccessBottomSheet() { + val authSession = stateFlow.value.payload()?.authSession + val notice = authSession?.display?.text?.consent?.dataAccessNotice ?: return + presentSheet( + content = DataAccess(notice), + referrer = pane, + ) + } + + fun onViewEffectLaunched() { + setState { + copy(viewEffect = null) + } + } + + fun onCancelClick() = withState { state -> + if (state.inModal) { + eventTracker.track(PrepaneClickCancel(pane = pane)) + } else { + eventTracker.track(PrepaneClickChooseAnotherBank(pane = pane)) + } + + viewModelScope.launch { + // set loading state while cancelling the active auth session, and navigate back + setState { copy(authenticationStatus = Loading(value = Status(Action.CANCELLING))) } + runCatching { + val authSession = requireNotNull( + getOrFetchSync(refetchCondition = IfMissingActiveAuthSession).manifest.activeAuthSession + ) + cancelAuthorizationSession(authSession.id) + } + if (state.inModal) { + cancelInModal() + } else { + cancelInFullscreen() + } + } + } + + private fun cancelInModal() { + navigationManager.tryNavigateBack() + } + + private fun cancelInFullscreen() { + navigationManager.tryNavigateTo( + route = Destination.InstitutionPicker(referrer = pane), + popUpTo = PopUpToBehavior.Current( + inclusive = true, + ), + ) + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/model/AuthorizationRepairResponse.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/AuthorizationRepairResponse.kt new file mode 100644 index 00000000000..8991c72b50b --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/model/AuthorizationRepairResponse.kt @@ -0,0 +1,15 @@ +package com.stripe.android.financialconnections.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class AuthorizationRepairResponse( + val id: String, + val url: String, + val flow: String, + val institution: FinancialConnectionsInstitution, + val display: Display, + @SerialName("is_oauth") + val isOAuth: Boolean, +) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt index 8c036f20fcd..a7a0dc21e38 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt @@ -3,15 +3,15 @@ package com.stripe.android.financialconnections.repository import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.di.ActivityRetainedScope import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository.State import kotlinx.parcelize.Parcelize import javax.inject.Inject -import javax.inject.Singleton /** * Repository for storing the core authorization pending repair. */ -@Singleton +@ActivityRetainedScope internal class CoreAuthorizationPendingNetworkingRepairRepository @Inject constructor( savedStateHandle: SavedStateHandle, private val logger: Logger, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt index f77dd57e986..ce4c0429952 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt @@ -7,9 +7,11 @@ import com.stripe.android.core.exception.AuthenticationException import com.stripe.android.core.exception.InvalidRequestException import com.stripe.android.core.networking.ApiRequest import com.stripe.android.financialconnections.analytics.AuthSessionEvent +import com.stripe.android.financialconnections.model.AuthorizationRepairResponse import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.SynchronizeSessionResponse import com.stripe.android.financialconnections.network.FinancialConnectionsRequestExecutor import com.stripe.android.financialconnections.network.NetworkConstants @@ -109,6 +111,12 @@ internal interface FinancialConnectionsManifestRepository { sessionId: String ): FinancialConnectionsAuthorizationSession + suspend fun repairAuthorizationSession( + clientSecret: String, + coreAuthorization: String, + applicationId: String, + ): FinancialConnectionsAuthorizationSession + /** * Save the authorized bank accounts to Link. * @@ -344,6 +352,36 @@ private class FinancialConnectionsManifestRepositoryImpl( updateCachedActiveAuthSession("retrieveAuthorizationSession", it) } + override suspend fun repairAuthorizationSession( + clientSecret: String, + coreAuthorization: String, + applicationId: String, + ): FinancialConnectionsAuthorizationSession { + val repairSession = requestExecutor.execute( + request = apiRequestFactory.createPost( + url = "${ApiRequest.API_HOST}/v1/connections/repair_sessions/generate_url", + options = provideApiRequestOptions(useConsumerPublishableKey = true), + params = mapOf( + NetworkConstants.PARAMS_CLIENT_SECRET to clientSecret, + "core_authorization" to coreAuthorization, + "return_url" to "auth-redirect/$applicationId", + ) + ), + AuthorizationRepairResponse.serializer() + ) + + return FinancialConnectionsAuthorizationSession( + id = repairSession.id, + url = repairSession.url, + flow = repairSession.flow, + display = repairSession.display, + _isOAuth = repairSession.isOAuth, + nextPane = Pane.SUCCESS, // TODO + ).also { + updateCachedActiveAuthSession("repairAuthorizationSession", it) + } + } + override suspend fun completeAuthorizationSession( clientSecret: String, sessionId: String, diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/AbsFinancialConnectionsManifestRepository.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/AbsFinancialConnectionsManifestRepository.kt index 80e8cfecb06..50bc8ec432c 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/AbsFinancialConnectionsManifestRepository.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/AbsFinancialConnectionsManifestRepository.kt @@ -43,6 +43,14 @@ internal abstract class AbsFinancialConnectionsManifestRepository : FinancialCon TODO("Not yet implemented") } + override suspend fun repairAuthorizationSession( + clientSecret: String, + coreAuthorization: String, + applicationId: String + ): FinancialConnectionsAuthorizationSession { + TODO("Not yet implemented") + } + override suspend fun cancelAuthorizationSession( clientSecret: String, sessionId: String diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsManifestRepository.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsManifestRepository.kt index 416514d5c4e..a1c198283e9 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsManifestRepository.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/networking/FakeFinancialConnectionsManifestRepository.kt @@ -48,6 +48,14 @@ internal class FakeFinancialConnectionsManifestRepository : FinancialConnections authSessionEvents: List ): FinancialConnectionsAuthorizationSession = postAuthSessionEvent() + override suspend fun repairAuthorizationSession( + clientSecret: String, + coreAuthorization: String, + applicationId: String + ): FinancialConnectionsAuthorizationSession { + TODO("Not yet implemented") + } + override suspend fun completeAuthorizationSession( clientSecret: String, sessionId: String, From 110ded46a1dc6606d187b59b6bba56bf8cab3481 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 31 Jan 2025 16:48:37 -0500 Subject: [PATCH 02/10] Fix test issue with workaround --- .../features/partnerauth/SupportabilityViewModelTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt index 6332fed939c..f6d2e519c76 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt @@ -28,6 +28,7 @@ import com.stripe.android.financialconnections.utils.TestHandleError import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -78,6 +79,9 @@ internal class SupportabilityViewModelTest { createViewModel() + // This is a bad workaround. Find something better! + advanceUntilIdle() + handleError.assertError( extraMessage = "Error fetching payload / posting AuthSession", error = unplannedDowntimeError, @@ -98,6 +102,9 @@ internal class SupportabilityViewModelTest { val viewModel = createViewModel(SharedPartnerAuthState(Pane.PARTNER_AUTH_DRAWER)) + // This is a bad workaround. Find something better! + advanceUntilIdle() + eventTracker.assertContainsEvent( "linked_accounts.pane.loaded", mapOf( From 149f6be615fc340dbef82785380e7493d7030ba2 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 7 Feb 2025 11:21:50 -0500 Subject: [PATCH 03/10] Remove need for `SharedPartnerAuthViewModel` --- ...inancialConnectionsSheetNativeComponent.kt | 2 - .../bankauthrepair/BankAuthRepairScreen.kt | 25 - .../bankauthrepair/BankAuthRepairViewModel.kt | 108 ---- .../features/partnerauth/PartnerAuthScreen.kt | 7 +- .../partnerauth/PartnerAuthViewModel.kt | 524 ++++++++++++++++-- .../partnerauth/SharedPartnerAuthState.kt | 5 - .../partnerauth/SharedPartnerAuthViewModel.kt | 493 ---------------- .../navigation/Destination.kt | 7 +- 8 files changed, 495 insertions(+), 676 deletions(-) delete mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt delete mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt delete mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt index 400bd490684..34e0744b67a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetNativeComponent.kt @@ -6,7 +6,6 @@ import com.stripe.android.financialconnections.FinancialConnectionsSheet import com.stripe.android.financialconnections.features.accountpicker.AccountPickerViewModel import com.stripe.android.financialconnections.features.accountupdate.AccountUpdateRequiredViewModel import com.stripe.android.financialconnections.features.attachpayment.AttachPaymentViewModel -import com.stripe.android.financialconnections.features.bankauthrepair.BankAuthRepairViewModel import com.stripe.android.financialconnections.features.consent.ConsentViewModel import com.stripe.android.financialconnections.features.error.ErrorViewModel import com.stripe.android.financialconnections.features.exit.ExitViewModel @@ -50,7 +49,6 @@ internal interface FinancialConnectionsSheetNativeComponent { val manualEntryViewModelFactory: ManualEntryViewModel.Factory val manualEntrySuccessViewModelFactory: ManualEntrySuccessViewModel.Factory val partnerAuthViewModelFactory: PartnerAuthViewModel.Factory - val bankAuthRepairViewModelFactory: BankAuthRepairViewModel.Factory val successViewModelFactory: SuccessViewModel.Factory val attachPaymentViewModelFactory: AttachPaymentViewModel.Factory val resetViewModelFactory: ResetViewModel.Factory diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt deleted file mode 100644 index 9babb2ddfdb..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairScreen.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.stripe.android.financialconnections.features.bankauthrepair - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import com.stripe.android.financialconnections.features.common.SharedPartnerAuth -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState -import com.stripe.android.financialconnections.presentation.paneViewModel -import com.stripe.android.uicore.utils.collectAsState - -@Composable -internal fun BankAuthRepairScreen() { - // step view model - val viewModel: BankAuthRepairViewModel = paneViewModel { BankAuthRepairViewModel.factory(it) } - val state: State = viewModel.stateFlow.collectAsState() - - SharedPartnerAuth( - state = state.value, - onContinueClick = viewModel::onLaunchAuthClick, - onCancelClick = viewModel::onCancelClick, - onClickableTextClick = viewModel::onClickableTextClick, - onWebAuthFlowFinished = viewModel::onWebAuthFlowFinished, - onViewEffectLaunched = viewModel::onViewEffectLaunched, - inModal = false - ) -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt deleted file mode 100644 index 385d902819e..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.stripe.android.financialconnections.features.bankauthrepair - -import android.os.Parcelable -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.stripe.android.core.Logger -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker -import com.stripe.android.financialconnections.browser.BrowserManager -import com.stripe.android.financialconnections.di.APPLICATION_ID -import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent -import com.stripe.android.financialconnections.domain.CancelAuthorizationSession -import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession -import com.stripe.android.financialconnections.domain.GetOrFetchSync -import com.stripe.android.financialconnections.domain.HandleError -import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator -import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults -import com.stripe.android.financialconnections.domain.PostAuthSessionEvent -import com.stripe.android.financialconnections.domain.PostAuthorizationSession -import com.stripe.android.financialconnections.domain.RepairAuthorizationSession -import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession -import com.stripe.android.financialconnections.features.notice.PresentSheet -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthViewModel -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.SynchronizeSessionResponse -import com.stripe.android.financialconnections.navigation.NavigationManager -import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository -import com.stripe.android.financialconnections.utils.UriUtils -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.parcelize.Parcelize -import javax.inject.Named - -internal class BankAuthRepairViewModel @AssistedInject constructor( - completeAuthorizationSession: CompleteAuthorizationSession, - createAuthorizationSession: PostAuthorizationSession, - cancelAuthorizationSession: CancelAuthorizationSession, - retrieveAuthorizationSession: RetrieveAuthorizationSession, - eventTracker: FinancialConnectionsAnalyticsTracker, - @Named(APPLICATION_ID) applicationId: String, - uriUtils: UriUtils, - postAuthSessionEvent: PostAuthSessionEvent, - getOrFetchSync: GetOrFetchSync, - browserManager: BrowserManager, - handleError: HandleError, - navigationManager: NavigationManager, - pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, - logger: Logger, - presentSheet: PresentSheet, - @Assisted initialState: SharedPartnerAuthState, - nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, - private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, - private val repairAuthSession: RepairAuthorizationSession, -) : SharedPartnerAuthViewModel( - completeAuthorizationSession, - createAuthorizationSession, - cancelAuthorizationSession, - retrieveAuthorizationSession, - eventTracker, - applicationId, - uriUtils, - postAuthSessionEvent, - getOrFetchSync, - browserManager, - handleError, - navigationManager, - pollAuthorizationSessionOAuthResults, - logger, - presentSheet, - initialState, - nativeAuthFlowCoordinator, -) { - - override suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload { - val authorization = requireNotNull(pendingRepairRepository.get()?.coreAuthorization) - val activeInstitution = requireNotNull(sync.manifest.activeInstitution) - - val authSession = repairAuthSession(authorization) - - return Payload( - isStripeDirect = sync.manifest.isStripeDirect ?: false, - institution = activeInstitution, - authSession = authSession, - ) - } - - @Parcelize - data class Args(val pane: Pane) : Parcelable - - @AssistedFactory - interface Factory { - fun create(initialState: SharedPartnerAuthState): BankAuthRepairViewModel - } - - internal companion object { - fun factory(parentComponent: FinancialConnectionsSheetNativeComponent): ViewModelProvider.Factory = - viewModelFactory { - initializer { - parentComponent.bankAuthRepairViewModelFactory.create(SharedPartnerAuthState(Args(PANE))) - } - } - - val PANE = Pane.BANK_AUTH_REPAIR - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt index 6319f2bc45e..e11db61abc9 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthScreen.kt @@ -8,11 +8,14 @@ import com.stripe.android.financialconnections.presentation.paneViewModel import com.stripe.android.uicore.utils.collectAsState @Composable -internal fun PartnerAuthScreen(inModal: Boolean) { +internal fun PartnerAuthScreen( + pane: Pane, + inModal: Boolean +) { val viewModel: PartnerAuthViewModel = paneViewModel { PartnerAuthViewModel.factory( parentComponent = it, - args = PartnerAuthViewModel.Args(inModal, Pane.PARTNER_AUTH) + args = PartnerAuthViewModel.Args(inModal, pane) ) } val state by viewModel.stateFlow.collectAsState() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt index ec4e52afeba..24f1d289f80 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt @@ -1,74 +1,140 @@ package com.stripe.android.financialconnections.features.partnerauth import android.os.Parcelable +import android.webkit.URLUtil +import androidx.core.net.toUri import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.FinancialConnections +import com.stripe.android.financialconnections.analytics.AuthSessionEvent +import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Launched +import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Loaded +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionOpened +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionRetrieved +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionUrlReceived +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickCancel +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickChooseAnotherBank +import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickContinue import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Name +import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.browser.BrowserManager import com.stripe.android.financialconnections.di.APPLICATION_ID import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent import com.stripe.android.financialconnections.domain.CancelAuthorizationSession import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.IfMissingActiveAuthSession import com.stripe.android.financialconnections.domain.HandleError import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults import com.stripe.android.financialconnections.domain.PostAuthSessionEvent import com.stripe.android.financialconnections.domain.PostAuthorizationSession +import com.stripe.android.financialconnections.domain.RepairAuthorizationSession import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession +import com.stripe.android.financialconnections.exception.FinancialConnectionsError +import com.stripe.android.financialconnections.exception.PartnerAuthError +import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException +import com.stripe.android.financialconnections.features.common.enableRetrieveAuthSession +import com.stripe.android.financialconnections.features.notice.NoticeSheetState.NoticeSheetContent.DataAccess import com.stripe.android.financialconnections.features.notice.PresentSheet +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect.OpenPartnerAuth +import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.SynchronizeSessionResponse +import com.stripe.android.financialconnections.navigation.Destination +import com.stripe.android.financialconnections.navigation.Destination.AccountPicker import com.stripe.android.financialconnections.navigation.NavigationManager +import com.stripe.android.financialconnections.navigation.PopUpToBehavior +import com.stripe.android.financialconnections.navigation.destination +import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate +import com.stripe.android.financialconnections.presentation.Async.Fail +import com.stripe.android.financialconnections.presentation.Async.Loading +import com.stripe.android.financialconnections.presentation.Async.Uninitialized +import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel +import com.stripe.android.financialconnections.presentation.WebAuthFlowState +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.utils.UriUtils +import com.stripe.android.financialconnections.utils.error import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import java.util.Date import javax.inject.Named +import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus as Status internal class PartnerAuthViewModel @AssistedInject constructor( - completeAuthorizationSession: CompleteAuthorizationSession, - createAuthorizationSession: PostAuthorizationSession, - cancelAuthorizationSession: CancelAuthorizationSession, - retrieveAuthorizationSession: RetrieveAuthorizationSession, - eventTracker: FinancialConnectionsAnalyticsTracker, - @Named(APPLICATION_ID) applicationId: String, - uriUtils: UriUtils, - postAuthSessionEvent: PostAuthSessionEvent, - getOrFetchSync: GetOrFetchSync, - browserManager: BrowserManager, - handleError: HandleError, - navigationManager: NavigationManager, - pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, - logger: Logger, - presentSheet: PresentSheet, - @Assisted initialState: SharedPartnerAuthState, + private val completeAuthorizationSession: CompleteAuthorizationSession, + private val createAuthorizationSession: PostAuthorizationSession, + private val cancelAuthorizationSession: CancelAuthorizationSession, + private val retrieveAuthorizationSession: RetrieveAuthorizationSession, + private val eventTracker: FinancialConnectionsAnalyticsTracker, + @Named(APPLICATION_ID) private val applicationId: String, + private val uriUtils: UriUtils, + private val postAuthSessionEvent: PostAuthSessionEvent, + private val getOrFetchSync: GetOrFetchSync, + private val browserManager: BrowserManager, + private val handleError: HandleError, + private val navigationManager: NavigationManager, + private val pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, + private val logger: Logger, + private val presentSheet: PresentSheet, + private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, + private val repairAuthSession: RepairAuthorizationSession, + @Assisted private val initialState: SharedPartnerAuthState, nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, -) : SharedPartnerAuthViewModel( - completeAuthorizationSession, - createAuthorizationSession, - cancelAuthorizationSession, - retrieveAuthorizationSession, - eventTracker, - applicationId, - uriUtils, - postAuthSessionEvent, - getOrFetchSync, - browserManager, - handleError, - navigationManager, - pollAuthorizationSessionOAuthResults, - logger, - presentSheet, - initialState, - nativeAuthFlowCoordinator, -) { - - override suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload { +) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { + + private val pane: Pane + get() = initialState.pane + + init { + handleErrors() + launchBrowserIfNonOauth() + initializeState() + } + + override fun updateTopAppBar(state: SharedPartnerAuthState): TopAppBarStateUpdate? { + return if (state.inModal) { + null + } else { + TopAppBarStateUpdate( + pane = state.pane, + allowBackNavigation = state.canNavigateBack, + error = state.payload.error, + ) + } + } + + private suspend fun initializeBankAuthRepair( + sync: SynchronizeSessionResponse, + ): Payload { + val authorization = requireNotNull(pendingRepairRepository.get()?.coreAuthorization) + val activeInstitution = requireNotNull(sync.manifest.activeInstitution) + + val authSession = repairAuthSession(authorization) + + return Payload( + isStripeDirect = sync.manifest.isStripeDirect ?: false, + institution = activeInstitution, + authSession = authSession, + ) + } + + private suspend fun initializePartnerAuth( + sync: SynchronizeSessionResponse, + ): Payload { val manifest = sync.manifest val authSession = manifest.activeAuthSession ?: createAuthorizationSession( institution = requireNotNull(manifest.activeInstitution), @@ -81,6 +147,390 @@ internal class PartnerAuthViewModel @AssistedInject constructor( ) } + private fun initializeState() { + suspend { + val sync = getOrFetchSync() + if (pane == Pane.BANK_AUTH_REPAIR) { + initializeBankAuthRepair(sync) + } else { + initializePartnerAuth(sync) + } + }.execute { + copy(payload = it) + } + } + + private fun recreateAuthSession() = suspend { + val launchedEvent = Launched(Date()) + val sync: SynchronizeSessionResponse = getOrFetchSync() + val manifest: FinancialConnectionsSessionManifest = sync.manifest + val authSession = createAuthorizationSession( + institution = requireNotNull(manifest.activeInstitution), + sync = sync + ) + logger.debug("Created auth session ${authSession.id}") + Payload( + authSession = authSession, + institution = requireNotNull(manifest.activeInstitution), + isStripeDirect = manifest.isStripeDirect ?: false + ).also { + // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. + val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } + postAuthSessionEvent( + authSession.id, + listOfNotNull(launchedEvent, loadedEvent) + ) + } + }.execute( + // keeps existing payload to prevent showing full-screen loading. + retainValue = SharedPartnerAuthState::payload + ) { + copy(payload = it) + } + + private fun launchBrowserIfNonOauth() { + onAsync( + prop = SharedPartnerAuthState::payload, + onSuccess = { + // launch auth for non-OAuth (skip pre-pane). + if (!it.authSession.isOAuth) { + launchAuthInBrowser(it.authSession) + } + } + ) + } + + private fun handleErrors() { + onAsync( + SharedPartnerAuthState::payload, + onFail = { + handleError( + extraMessage = "Error fetching payload / posting AuthSession", + error = it, + pane = pane, + displayErrorScreen = true + ) + }, + onSuccess = { eventTracker.track(PaneLoaded(pane)) } + ) + onAsync( + SharedPartnerAuthState::authenticationStatus, + onFail = { + handleError( + extraMessage = "Error with authentication status", + error = if (it is FinancialConnectionsError) it else PartnerAuthError(it.message), + pane = pane, + displayErrorScreen = true + ) + } + ) + } + + fun onLaunchAuthClick() { + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + + withState { state -> + val authSession = requireNotNull(state.payload()?.authSession) { + "Payload shouldn't be null when the user launches the auth flow" + } + + reportOAuthLaunched(authSession.id) + launchAuthInBrowser(authSession) + } + } + + private fun reportOAuthLaunched(sessionId: String) { + postAuthSessionEvent(sessionId, AuthSessionEvent.OAuthLaunched(Date())) + eventTracker.track(PrepaneClickContinue(pane)) + } + + private fun launchAuthInBrowser(authSession: FinancialConnectionsAuthorizationSession) { + authSession.browserReadyUrl()?.let { url -> + setState { copy(viewEffect = OpenPartnerAuth(url)) } + eventTracker.track( + AuthSessionOpened( + id = authSession.id, + pane = pane, + flow = authSession.flow, + defaultBrowser = browserManager.getPackageToHandleUri(uri = url.toUri()), + ) + ) + } + } + + /** + * Auth Session url after clearing the deep link prefix (required for non-native app2app flows). + */ + private fun FinancialConnectionsAuthorizationSession.browserReadyUrl(): String? = + url?.replaceFirst("stripe-auth://native-redirect/$applicationId/", "") + + fun onWebAuthFlowFinished( + webStatus: WebAuthFlowState + ) { + logger.debug("Web AuthFlow status received $webStatus") + viewModelScope.launch { + when (webStatus) { + is WebAuthFlowState.Canceled -> { + onAuthCancelled(webStatus.url) + } + + is WebAuthFlowState.Failed -> { + onAuthFailed(webStatus.url, webStatus.message, webStatus.reason) + } + + WebAuthFlowState.InProgress -> { + setState { + copy( + authenticationStatus = Loading(Status(Action.AUTHENTICATING)) + ) + } + } + + is WebAuthFlowState.Success -> { + completeAuthorizationSession(webStatus.url) + } + + WebAuthFlowState.Uninitialized -> {} + } + } + } + + private suspend fun onAuthFailed( + url: String, + message: String, + reason: String? + ) { + val error = WebAuthFlowFailedException(message, reason) + kotlin.runCatching { + val authSession = getOrFetchSync().manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url, + authSessionId = authSession?.id, + status = "failed" + ) + ) + eventTracker.logError( + extraMessage = "Auth failed, cancelling AuthSession", + error = error, + logger = logger, + pane = pane + ) + when { + authSession != null -> { + postAuthSessionEvent(authSession.id, AuthSessionEvent.Failure(Date(), error)) + cancelAuthorizationSession(authSession.id) + } + + else -> logger.debug("Could not find AuthSession to cancel.") + } + setState { copy(authenticationStatus = Fail(error)) } + }.onFailure { + eventTracker.logError( + extraMessage = "failed cancelling session after failed web flow", + error = it, + logger = logger, + pane = pane + ) + } + } + + private suspend fun onAuthCancelled(url: String?) { + kotlin.runCatching { + logger.debug("Auth cancelled, cancelling AuthSession") + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + val manifest = getOrFetchSync( + refetchCondition = IfMissingActiveAuthSession + ).manifest + val authSession = manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url ?: "none", + authSessionId = authSession?.id, + status = "cancelled" + ) + ) + requireNotNull(authSession) + if (manifest.enableRetrieveAuthSession()) { + // if the client canceled mid-flow (either by closing the browser or + // cancelling on the institution page), retrieve the auth session + // and try to recover if possible. + val retrievedAuthSession = retrieveAuthorizationSession(authSession.id) + val nextPane = retrievedAuthSession.nextPane + eventTracker.track( + AuthSessionRetrieved( + authSessionId = retrievedAuthSession.id, + nextPane = nextPane + ) + ) + if (nextPane == pane) { + // auth session was not completed, proceed with cancellation + cancelAuthSessionAndContinue(authSession = retrievedAuthSession) + } else { + // auth session succeeded although client didn't retrieve any deeplink. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) + navigationManager.tryNavigateTo(nextPane.destination(referrer = pane)) + } + } else { + cancelAuthSessionAndContinue(authSession) + } + }.onFailure { + eventTracker.logError( + "failed cancelling session after cancelled web flow. url: $url", + it, + logger, + pane + ) + setState { copy(authenticationStatus = Fail(it)) } + } + } + + /** + * Cancels the given [authSession] and navigates to the next pane (non-OAuth) / retries (OAuth). + */ + private suspend fun cancelAuthSessionAndContinue( + authSession: FinancialConnectionsAuthorizationSession + ) { + val result = cancelAuthorizationSession(authSession.id) + if (authSession.isOAuth) { + // For OAuth institutions, create a new session and navigate to its nextPane (prepane). + logger.debug("Creating a new session for this OAuth institution") + // Send retry event as we're presenting the prepane again. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Retry(Date())) + // for OAuth institutions, we remain on the pre-pane, + // but create a brand new auth session + setState { copy(authenticationStatus = Uninitialized) } + recreateAuthSession() + } else { + // For non-OAuth institutions, navigate to Session cancellation's next pane. + postAuthSessionEvent(authSession.id, AuthSessionEvent.Cancel(Date())) + navigationManager.tryNavigateTo( + route = result.nextPane.destination(referrer = pane), + popUpTo = PopUpToBehavior.Current(inclusive = true), + ) + } + } + + private suspend fun completeAuthorizationSession(url: String) { + kotlin.runCatching { + setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } + val authSession = getOrFetchSync( + refetchCondition = IfMissingActiveAuthSession + ).manifest.activeAuthSession + eventTracker.track( + AuthSessionUrlReceived( + pane = pane, + url = url, + authSessionId = authSession?.id, + status = "success" + ) + ) + requireNotNull(authSession) + postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) + val nextPane = if (authSession.isOAuth) { + logger.debug("Web AuthFlow completed! waiting for oauth results") + val oAuthResults = pollAuthorizationSessionOAuthResults(authSession) + logger.debug("OAuth results received! completing session") + val updatedSession = completeAuthorizationSession( + authorizationSessionId = authSession.id, + publicToken = oAuthResults.publicToken + ) + logger.debug("Session authorized!") + updatedSession.nextPane.destination(referrer = pane) + } else { + AccountPicker(referrer = pane) + } + FinancialConnections.emitEvent(Name.INSTITUTION_AUTHORIZED) + navigationManager.tryNavigateTo(nextPane) + }.onFailure { + eventTracker.logError( + extraMessage = "failed authorizing session", + error = it, + logger = logger, + pane = pane + ) + setState { copy(authenticationStatus = Fail(it)) } + } + } + + // if clicked uri contains an eventName query param, track click event. + fun onClickableTextClick(uri: String) = viewModelScope.launch { + uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> + eventTracker.track(Click(eventName, pane = pane)) + } + if (URLUtil.isNetworkUrl(uri)) { + setState { + copy( + viewEffect = ViewEffect.OpenUrl( + uri, + Date().time + ) + ) + } + } else { + val managedUri = SharedPartnerAuthState.ClickableText.entries + .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } + when (managedUri) { + SharedPartnerAuthState.ClickableText.DATA -> presentDataAccessBottomSheet() + null -> logger.error("Unrecognized clickable text: $uri") + } + } + } + + private fun presentDataAccessBottomSheet() { + val authSession = stateFlow.value.payload()?.authSession + val notice = authSession?.display?.text?.consent?.dataAccessNotice ?: return + presentSheet( + content = DataAccess(notice), + referrer = pane, + ) + } + + fun onViewEffectLaunched() { + setState { + copy(viewEffect = null) + } + } + + fun onCancelClick() = withState { state -> + if (state.inModal) { + eventTracker.track(PrepaneClickCancel(pane = pane)) + } else { + eventTracker.track(PrepaneClickChooseAnotherBank(pane = pane)) + } + + viewModelScope.launch { + // set loading state while cancelling the active auth session, and navigate back + setState { copy(authenticationStatus = Loading(value = Status(Action.CANCELLING))) } + runCatching { + val authSession = requireNotNull( + getOrFetchSync(refetchCondition = IfMissingActiveAuthSession).manifest.activeAuthSession + ) + cancelAuthorizationSession(authSession.id) + } + if (state.inModal) { + cancelInModal() + } else { + cancelInFullscreen() + } + } + } + + private fun cancelInModal() { + navigationManager.tryNavigateBack() + } + + private fun cancelInFullscreen() { + navigationManager.tryNavigateTo( + route = Destination.InstitutionPicker(referrer = pane), + popUpTo = PopUpToBehavior.Current( + inclusive = true, + ), + ) + } + @Parcelize data class Args(val inModal: Boolean, val pane: Pane) : Parcelable diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt index 3db3782b569..ece34d12ff6 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt @@ -1,6 +1,5 @@ package com.stripe.android.financialconnections.features.partnerauth -import com.stripe.android.financialconnections.features.bankauthrepair.BankAuthRepairViewModel import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsInstitution import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane @@ -26,10 +25,6 @@ internal data class SharedPartnerAuthState( inModal = args.inModal, ) - constructor(args: BankAuthRepairViewModel.Args) : this( - pane = args.pane, - ) - data class Payload( val isStripeDirect: Boolean, val institution: FinancialConnectionsInstitution, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt deleted file mode 100644 index 7a62eb2e71d..00000000000 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt +++ /dev/null @@ -1,493 +0,0 @@ -package com.stripe.android.financialconnections.features.partnerauth - -import android.webkit.URLUtil -import androidx.core.net.toUri -import androidx.lifecycle.viewModelScope -import com.stripe.android.core.Logger -import com.stripe.android.financialconnections.FinancialConnections -import com.stripe.android.financialconnections.analytics.AuthSessionEvent -import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Launched -import com.stripe.android.financialconnections.analytics.AuthSessionEvent.Loaded -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionOpened -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionRetrieved -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.AuthSessionUrlReceived -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.Click -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PaneLoaded -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickCancel -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickChooseAnotherBank -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsEvent.PrepaneClickContinue -import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker -import com.stripe.android.financialconnections.analytics.FinancialConnectionsEvent.Name -import com.stripe.android.financialconnections.analytics.logError -import com.stripe.android.financialconnections.browser.BrowserManager -import com.stripe.android.financialconnections.domain.CancelAuthorizationSession -import com.stripe.android.financialconnections.domain.CompleteAuthorizationSession -import com.stripe.android.financialconnections.domain.GetOrFetchSync -import com.stripe.android.financialconnections.domain.GetOrFetchSync.RefetchCondition.IfMissingActiveAuthSession -import com.stripe.android.financialconnections.domain.HandleError -import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator -import com.stripe.android.financialconnections.domain.PollAuthorizationSessionOAuthResults -import com.stripe.android.financialconnections.domain.PostAuthSessionEvent -import com.stripe.android.financialconnections.domain.PostAuthorizationSession -import com.stripe.android.financialconnections.domain.RetrieveAuthorizationSession -import com.stripe.android.financialconnections.exception.FinancialConnectionsError -import com.stripe.android.financialconnections.exception.PartnerAuthError -import com.stripe.android.financialconnections.exception.WebAuthFlowFailedException -import com.stripe.android.financialconnections.features.common.enableRetrieveAuthSession -import com.stripe.android.financialconnections.features.notice.NoticeSheetState.NoticeSheetContent.DataAccess -import com.stripe.android.financialconnections.features.notice.PresentSheet -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus.Action -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.Payload -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.ViewEffect.OpenPartnerAuth -import com.stripe.android.financialconnections.model.FinancialConnectionsAuthorizationSession -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest -import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane -import com.stripe.android.financialconnections.model.SynchronizeSessionResponse -import com.stripe.android.financialconnections.navigation.Destination -import com.stripe.android.financialconnections.navigation.Destination.AccountPicker -import com.stripe.android.financialconnections.navigation.NavigationManager -import com.stripe.android.financialconnections.navigation.PopUpToBehavior -import com.stripe.android.financialconnections.navigation.destination -import com.stripe.android.financialconnections.navigation.topappbar.TopAppBarStateUpdate -import com.stripe.android.financialconnections.presentation.Async.Fail -import com.stripe.android.financialconnections.presentation.Async.Loading -import com.stripe.android.financialconnections.presentation.Async.Uninitialized -import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel -import com.stripe.android.financialconnections.presentation.WebAuthFlowState -import com.stripe.android.financialconnections.utils.UriUtils -import com.stripe.android.financialconnections.utils.error -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.Date -import com.stripe.android.financialconnections.features.partnerauth.SharedPartnerAuthState.AuthenticationStatus as Status - -private const val InitializationDelayMillis = 200L - -internal abstract class SharedPartnerAuthViewModel( - private val completeAuthorizationSession: CompleteAuthorizationSession, - protected val createAuthorizationSession: PostAuthorizationSession, - private val cancelAuthorizationSession: CancelAuthorizationSession, - private val retrieveAuthorizationSession: RetrieveAuthorizationSession, - private val eventTracker: FinancialConnectionsAnalyticsTracker, - private val applicationId: String, - private val uriUtils: UriUtils, - private val postAuthSessionEvent: PostAuthSessionEvent, - private val getOrFetchSync: GetOrFetchSync, - private val browserManager: BrowserManager, - private val handleError: HandleError, - private val navigationManager: NavigationManager, - private val pollAuthorizationSessionOAuthResults: PollAuthorizationSessionOAuthResults, - private val logger: Logger, - private val presentSheet: PresentSheet, - private val initialState: SharedPartnerAuthState, - nativeAuthFlowCoordinator: NativeAuthFlowCoordinator, -) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { - - private val pane: Pane - get() = initialState.pane - - init { - handleErrors() - launchBrowserIfNonOauth() - initializeState() - } - - abstract suspend fun fetchPayload(sync: SynchronizeSessionResponse): Payload - - override fun updateTopAppBar(state: SharedPartnerAuthState): TopAppBarStateUpdate? { - return if (state.inModal) { - null - } else { - TopAppBarStateUpdate( - pane = state.pane, - allowBackNavigation = state.canNavigateBack, - error = state.payload.error, - ) - } - } - - private fun initializeState() { - suspend { - // This is a bad workaround to prevent the subclass' fetchPayload from being called - // before the subclass' constructor is called. - delay(InitializationDelayMillis) - - val sync: SynchronizeSessionResponse = getOrFetchSync() - fetchPayload(sync) - }.execute { - copy(payload = it) - } - } - - private fun recreateAuthSession() = suspend { - val launchedEvent = Launched(Date()) - val sync: SynchronizeSessionResponse = getOrFetchSync() - val manifest: FinancialConnectionsSessionManifest = sync.manifest - val authSession = createAuthorizationSession( - institution = requireNotNull(manifest.activeInstitution), - sync = sync - ) - logger.debug("Created auth session ${authSession.id}") - Payload( - authSession = authSession, - institution = requireNotNull(manifest.activeInstitution), - isStripeDirect = manifest.isStripeDirect ?: false - ).also { - // just send loaded event on OAuth flows (prepane). Non-OAuth handled by shim. - val loadedEvent: Loaded? = Loaded(Date()).takeIf { authSession.isOAuth } - postAuthSessionEvent( - authSession.id, - listOfNotNull(launchedEvent, loadedEvent) - ) - } - }.execute( - // keeps existing payload to prevent showing full-screen loading. - retainValue = SharedPartnerAuthState::payload - ) { - copy(payload = it) - } - - private fun launchBrowserIfNonOauth() { - onAsync( - prop = SharedPartnerAuthState::payload, - onSuccess = { - // launch auth for non-OAuth (skip pre-pane). - if (!it.authSession.isOAuth) { - launchAuthInBrowser(it.authSession) - } - } - ) - } - - private fun handleErrors() { - onAsync( - SharedPartnerAuthState::payload, - onFail = { - handleError( - extraMessage = "Error fetching payload / posting AuthSession", - error = it, - pane = pane, - displayErrorScreen = true - ) - }, - onSuccess = { eventTracker.track(PaneLoaded(pane)) } - ) - onAsync( - SharedPartnerAuthState::authenticationStatus, - onFail = { - handleError( - extraMessage = "Error with authentication status", - error = if (it is FinancialConnectionsError) it else PartnerAuthError(it.message), - pane = pane, - displayErrorScreen = true - ) - } - ) - } - - fun onLaunchAuthClick() { - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - - withState { state -> - val authSession = requireNotNull(state.payload()?.authSession) { - "Payload shouldn't be null when the user launches the auth flow" - } - - reportOAuthLaunched(authSession.id) - launchAuthInBrowser(authSession) - } - } - - private fun reportOAuthLaunched(sessionId: String) { - postAuthSessionEvent(sessionId, AuthSessionEvent.OAuthLaunched(Date())) - eventTracker.track(PrepaneClickContinue(pane)) - } - - private fun launchAuthInBrowser(authSession: FinancialConnectionsAuthorizationSession) { - authSession.browserReadyUrl()?.let { url -> - setState { copy(viewEffect = OpenPartnerAuth(url)) } - eventTracker.track( - AuthSessionOpened( - id = authSession.id, - pane = pane, - flow = authSession.flow, - defaultBrowser = browserManager.getPackageToHandleUri(uri = url.toUri()), - ) - ) - } - } - - /** - * Auth Session url after clearing the deep link prefix (required for non-native app2app flows). - */ - private fun FinancialConnectionsAuthorizationSession.browserReadyUrl(): String? = - url?.replaceFirst("stripe-auth://native-redirect/$applicationId/", "") - - fun onWebAuthFlowFinished( - webStatus: WebAuthFlowState - ) { - logger.debug("Web AuthFlow status received $webStatus") - viewModelScope.launch { - when (webStatus) { - is WebAuthFlowState.Canceled -> { - onAuthCancelled(webStatus.url) - } - - is WebAuthFlowState.Failed -> { - onAuthFailed(webStatus.url, webStatus.message, webStatus.reason) - } - - WebAuthFlowState.InProgress -> { - setState { - copy( - authenticationStatus = Loading(Status(Action.AUTHENTICATING)) - ) - } - } - - is WebAuthFlowState.Success -> { - completeAuthorizationSession(webStatus.url) - } - - WebAuthFlowState.Uninitialized -> {} - } - } - } - - private suspend fun onAuthFailed( - url: String, - message: String, - reason: String? - ) { - val error = WebAuthFlowFailedException(message, reason) - kotlin.runCatching { - val authSession = getOrFetchSync().manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = pane, - url = url, - authSessionId = authSession?.id, - status = "failed" - ) - ) - eventTracker.logError( - extraMessage = "Auth failed, cancelling AuthSession", - error = error, - logger = logger, - pane = pane - ) - when { - authSession != null -> { - postAuthSessionEvent(authSession.id, AuthSessionEvent.Failure(Date(), error)) - cancelAuthorizationSession(authSession.id) - } - - else -> logger.debug("Could not find AuthSession to cancel.") - } - setState { copy(authenticationStatus = Fail(error)) } - }.onFailure { - eventTracker.logError( - extraMessage = "failed cancelling session after failed web flow", - error = it, - logger = logger, - pane = pane - ) - } - } - - private suspend fun onAuthCancelled(url: String?) { - kotlin.runCatching { - logger.debug("Auth cancelled, cancelling AuthSession") - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - val manifest = getOrFetchSync( - refetchCondition = IfMissingActiveAuthSession - ).manifest - val authSession = manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = pane, - url = url ?: "none", - authSessionId = authSession?.id, - status = "cancelled" - ) - ) - requireNotNull(authSession) - if (manifest.enableRetrieveAuthSession()) { - // if the client canceled mid-flow (either by closing the browser or - // cancelling on the institution page), retrieve the auth session - // and try to recover if possible. - val retrievedAuthSession = retrieveAuthorizationSession(authSession.id) - val nextPane = retrievedAuthSession.nextPane - eventTracker.track( - AuthSessionRetrieved( - authSessionId = retrievedAuthSession.id, - nextPane = nextPane - ) - ) - if (nextPane == pane) { - // auth session was not completed, proceed with cancellation - cancelAuthSessionAndContinue(authSession = retrievedAuthSession) - } else { - // auth session succeeded although client didn't retrieve any deeplink. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) - navigationManager.tryNavigateTo(nextPane.destination(referrer = pane)) - } - } else { - cancelAuthSessionAndContinue(authSession) - } - }.onFailure { - eventTracker.logError( - "failed cancelling session after cancelled web flow. url: $url", - it, - logger, - pane - ) - setState { copy(authenticationStatus = Fail(it)) } - } - } - - /** - * Cancels the given [authSession] and navigates to the next pane (non-OAuth) / retries (OAuth). - */ - private suspend fun cancelAuthSessionAndContinue( - authSession: FinancialConnectionsAuthorizationSession - ) { - val result = cancelAuthorizationSession(authSession.id) - if (authSession.isOAuth) { - // For OAuth institutions, create a new session and navigate to its nextPane (prepane). - logger.debug("Creating a new session for this OAuth institution") - // Send retry event as we're presenting the prepane again. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Retry(Date())) - // for OAuth institutions, we remain on the pre-pane, - // but create a brand new auth session - setState { copy(authenticationStatus = Uninitialized) } - recreateAuthSession() - } else { - // For non-OAuth institutions, navigate to Session cancellation's next pane. - postAuthSessionEvent(authSession.id, AuthSessionEvent.Cancel(Date())) - navigationManager.tryNavigateTo( - route = result.nextPane.destination(referrer = pane), - popUpTo = PopUpToBehavior.Current(inclusive = true), - ) - } - } - - private suspend fun completeAuthorizationSession(url: String) { - kotlin.runCatching { - setState { copy(authenticationStatus = Loading(value = Status(Action.AUTHENTICATING))) } - val authSession = getOrFetchSync( - refetchCondition = IfMissingActiveAuthSession - ).manifest.activeAuthSession - eventTracker.track( - AuthSessionUrlReceived( - pane = pane, - url = url, - authSessionId = authSession?.id, - status = "success" - ) - ) - requireNotNull(authSession) - postAuthSessionEvent(authSession.id, AuthSessionEvent.Success(Date())) - val nextPane = if (authSession.isOAuth) { - logger.debug("Web AuthFlow completed! waiting for oauth results") - val oAuthResults = pollAuthorizationSessionOAuthResults(authSession) - logger.debug("OAuth results received! completing session") - val updatedSession = completeAuthorizationSession( - authorizationSessionId = authSession.id, - publicToken = oAuthResults.publicToken - ) - logger.debug("Session authorized!") - updatedSession.nextPane.destination(referrer = pane) - } else { - AccountPicker(referrer = pane) - } - FinancialConnections.emitEvent(Name.INSTITUTION_AUTHORIZED) - navigationManager.tryNavigateTo(nextPane) - }.onFailure { - eventTracker.logError( - extraMessage = "failed authorizing session", - error = it, - logger = logger, - pane = pane - ) - setState { copy(authenticationStatus = Fail(it)) } - } - } - - // if clicked uri contains an eventName query param, track click event. - fun onClickableTextClick(uri: String) = viewModelScope.launch { - uriUtils.getQueryParameter(uri, "eventName")?.let { eventName -> - eventTracker.track(Click(eventName, pane = pane)) - } - if (URLUtil.isNetworkUrl(uri)) { - setState { - copy( - viewEffect = ViewEffect.OpenUrl( - uri, - Date().time - ) - ) - } - } else { - val managedUri = SharedPartnerAuthState.ClickableText.entries - .firstOrNull { uriUtils.compareSchemeAuthorityAndPath(it.value, uri) } - when (managedUri) { - SharedPartnerAuthState.ClickableText.DATA -> presentDataAccessBottomSheet() - null -> logger.error("Unrecognized clickable text: $uri") - } - } - } - - private fun presentDataAccessBottomSheet() { - val authSession = stateFlow.value.payload()?.authSession - val notice = authSession?.display?.text?.consent?.dataAccessNotice ?: return - presentSheet( - content = DataAccess(notice), - referrer = pane, - ) - } - - fun onViewEffectLaunched() { - setState { - copy(viewEffect = null) - } - } - - fun onCancelClick() = withState { state -> - if (state.inModal) { - eventTracker.track(PrepaneClickCancel(pane = pane)) - } else { - eventTracker.track(PrepaneClickChooseAnotherBank(pane = pane)) - } - - viewModelScope.launch { - // set loading state while cancelling the active auth session, and navigate back - setState { copy(authenticationStatus = Loading(value = Status(Action.CANCELLING))) } - runCatching { - val authSession = requireNotNull( - getOrFetchSync(refetchCondition = IfMissingActiveAuthSession).manifest.activeAuthSession - ) - cancelAuthorizationSession(authSession.id) - } - if (state.inModal) { - cancelInModal() - } else { - cancelInFullscreen() - } - } - } - - private fun cancelInModal() { - navigationManager.tryNavigateBack() - } - - private fun cancelInFullscreen() { - navigationManager.tryNavigateTo( - route = Destination.InstitutionPicker(referrer = pane), - popUpTo = PopUpToBehavior.Current( - inclusive = true, - ), - ) - } -} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt index 4797ded5c35..4be89abfd85 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/navigation/Destination.kt @@ -18,7 +18,6 @@ import androidx.navigation.navArgument import com.stripe.android.financialconnections.features.accountpicker.AccountPickerScreen import com.stripe.android.financialconnections.features.accountupdate.AccountUpdateRequiredModal import com.stripe.android.financialconnections.features.attachpayment.AttachPaymentScreen -import com.stripe.android.financialconnections.features.bankauthrepair.BankAuthRepairScreen import com.stripe.android.financialconnections.features.consent.ConsentScreen import com.stripe.android.financialconnections.features.error.ErrorScreen import com.stripe.android.financialconnections.features.exit.ExitModal @@ -110,14 +109,14 @@ internal sealed class Destination( route = Pane.PARTNER_AUTH_DRAWER.value, closeWithoutConfirmation = false, logPaneLaunched = true, - composable = { PartnerAuthScreen(inModal = true) } + composable = { PartnerAuthScreen(pane = Pane.PARTNER_AUTH, inModal = true) } ) data object PartnerAuth : Destination( route = Pane.PARTNER_AUTH.value, closeWithoutConfirmation = false, logPaneLaunched = true, - composable = { PartnerAuthScreen(inModal = false) } + composable = { PartnerAuthScreen(pane = Pane.PARTNER_AUTH, inModal = false) } ) data object AccountPicker : Destination( @@ -235,7 +234,7 @@ internal sealed class Destination( route = Pane.BANK_AUTH_REPAIR.value, closeWithoutConfirmation = false, logPaneLaunched = true, - composable = { BankAuthRepairScreen() } + composable = { PartnerAuthScreen(pane = Pane.BANK_AUTH_REPAIR, inModal = false) } ) data object ManualEntrySuccess : Destination( From aa2a906e91620e6a0cb9e739882d039938af4ed0 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 7 Feb 2025 11:32:57 -0500 Subject: [PATCH 04/10] Don't show `saved to Link` on networked relink --- .../domain/SaveAccountToLink.kt | 22 +++++++++++++++---- .../accountpicker/AccountPickerViewModel.kt | 4 ++++ .../networkinglinksignup/LinkSignupHandler.kt | 1 + ...tworkingSaveToLinkVerificationViewModel.kt | 1 + ...zationPendingNetworkingRepairRepository.kt | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt index d2f2725b15e..b79b4f86ccf 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt @@ -33,7 +33,11 @@ internal class SaveAccountToLink @Inject constructor( country: String, shouldPollAccountNumbers: Boolean, ): FinancialConnectionsSessionManifest { - return ensureReadyAccounts(shouldPollAccountNumbers, selectedAccounts) { selectedAccountIds -> + return ensureReadyAccounts( + shouldPollAccountNumbers = shouldPollAccountNumbers, + isRelink = false, + partnerAccounts = selectedAccounts, + ) { selectedAccountIds -> repository.postSaveAccountsToLink( clientSecret = configuration.financialConnectionsSessionClientSecret, email = email, @@ -50,8 +54,13 @@ internal class SaveAccountToLink @Inject constructor( consumerSessionClientSecret: String, selectedAccounts: List?, shouldPollAccountNumbers: Boolean, + isRelink: Boolean, ): FinancialConnectionsSessionManifest { - return ensureReadyAccounts(shouldPollAccountNumbers, selectedAccounts) { selectedAccountIds -> + return ensureReadyAccounts( + shouldPollAccountNumbers = shouldPollAccountNumbers, + isRelink = isRelink, + partnerAccounts = selectedAccounts, + ) { selectedAccountIds -> repository.postSaveAccountsToLink( clientSecret = configuration.financialConnectionsSessionClientSecret, email = null, @@ -66,6 +75,7 @@ internal class SaveAccountToLink @Inject constructor( private suspend fun ensureReadyAccounts( shouldPollAccountNumbers: Boolean, + isRelink: Boolean, partnerAccounts: List?, action: suspend (Set?) -> FinancialConnectionsSessionManifest, ): FinancialConnectionsSessionManifest { @@ -90,9 +100,13 @@ internal class SaveAccountToLink @Inject constructor( }.mapCatching { action(selectedAccountIds) }.onSuccess { manifest -> - storeSavedToLinkMessage(manifest, selectedAccountIds.size) + if (!isRelink) { + storeSavedToLinkMessage(manifest, selectedAccountIds.size) + } }.onFailure { - storeFailedToSaveToLinkMessage(selectedAccountIds.size) + if (!isRelink) { + storeFailedToSaveToLinkMessage(selectedAccountIds.size) + } }.getOrThrow() } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt index be3ace3344a..f89c788287d 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt @@ -48,6 +48,7 @@ import com.stripe.android.financialconnections.presentation.Async.Loading import com.stripe.android.financialconnections.presentation.Async.Uninitialized import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel import com.stripe.android.financialconnections.repository.ConsumerSessionProvider +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.ui.HandleClickableUrl import com.stripe.android.financialconnections.utils.error import com.stripe.android.financialconnections.utils.measureTimeMillis @@ -70,6 +71,7 @@ internal class AccountPickerViewModel @AssistedInject constructor( private val logger: Logger, private val pollAuthorizationSessionAccounts: PollAuthorizationSessionAccounts, private val presentSheet: PresentSheet, + private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, ) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { init { @@ -334,10 +336,12 @@ internal class AccountPickerViewModel @AssistedInject constructor( if (manifest.isDataFlow && manifest.canSaveAccountsToLink && consumerSessionClientSecret != null) { // In the data flow, we save accounts to Link in this screen. In the payment flow, // it happens in the AttachPaymentScreen. + val isRelink = pendingRepairRepository.get() != null saveAccountToLink.existing( consumerSessionClientSecret = consumerSessionClientSecret, selectedAccounts = accountsList.data.toCachedPartnerAccounts(), shouldPollAccountNumbers = manifest.isDataFlow, + isRelink = isRelink, ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt index 4a93b0266ff..8a92a7eb19a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt @@ -148,6 +148,7 @@ internal class LinkSignupHandlerForNetworking @Inject constructor( consumerSessionClientSecret = signup.consumerSession.clientSecret, selectedAccounts = selectedAccounts, shouldPollAccountNumbers = manifest.isDataFlow, + isRelink = false, ) } else { // ** Legacy signup endpoint on unverified flows: 1 request ** diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt index fecc8a28712..9e113edf9da 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt @@ -150,6 +150,7 @@ internal class NetworkingSaveToLinkVerificationViewModel @AssistedInject constru consumerSessionClientSecret = payload.consumerSessionClientSecret, selectedAccounts = accounts, shouldPollAccountNumbers = manifest.isDataFlow, + isRelink = false, ) } .onSuccess { eventTracker.track(VerificationSuccess(PANE)) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt index a7a0dc21e38..7e385459219 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/CoreAuthorizationPendingNetworkingRepairRepository.kt @@ -26,6 +26,6 @@ internal class CoreAuthorizationPendingNetworkingRepairRepository @Inject constr @Parcelize data class State( - val coreAuthorization: String? = null + val coreAuthorization: String, ) : Parcelable } From 2e4c1513178b41de23e818f556f53d46756fd0ea Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 7 Feb 2025 11:48:17 -0500 Subject: [PATCH 05/10] Clean up --- .../domain/SaveAccountToLink.kt | 12 ++++----- .../accountpicker/AccountPickerViewModel.kt | 4 +-- .../features/common/SharedPartnerAuth.kt | 2 +- .../networkinglinksignup/LinkSignupHandler.kt | 2 +- ...tworkingSaveToLinkVerificationViewModel.kt | 2 +- .../partnerauth/PartnerAuthViewModel.kt | 26 +++++++++---------- .../partnerauth/SharedPartnerAuthState.kt | 4 +-- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt index b79b4f86ccf..cad867d1561 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt @@ -35,7 +35,7 @@ internal class SaveAccountToLink @Inject constructor( ): FinancialConnectionsSessionManifest { return ensureReadyAccounts( shouldPollAccountNumbers = shouldPollAccountNumbers, - isRelink = false, + isNetworkingRelinkSession = false, partnerAccounts = selectedAccounts, ) { selectedAccountIds -> repository.postSaveAccountsToLink( @@ -54,11 +54,11 @@ internal class SaveAccountToLink @Inject constructor( consumerSessionClientSecret: String, selectedAccounts: List?, shouldPollAccountNumbers: Boolean, - isRelink: Boolean, + isNetworkingRelinkSession: Boolean, ): FinancialConnectionsSessionManifest { return ensureReadyAccounts( shouldPollAccountNumbers = shouldPollAccountNumbers, - isRelink = isRelink, + isNetworkingRelinkSession = isNetworkingRelinkSession, partnerAccounts = selectedAccounts, ) { selectedAccountIds -> repository.postSaveAccountsToLink( @@ -75,7 +75,7 @@ internal class SaveAccountToLink @Inject constructor( private suspend fun ensureReadyAccounts( shouldPollAccountNumbers: Boolean, - isRelink: Boolean, + isNetworkingRelinkSession: Boolean, partnerAccounts: List?, action: suspend (Set?) -> FinancialConnectionsSessionManifest, ): FinancialConnectionsSessionManifest { @@ -100,11 +100,11 @@ internal class SaveAccountToLink @Inject constructor( }.mapCatching { action(selectedAccountIds) }.onSuccess { manifest -> - if (!isRelink) { + if (!isNetworkingRelinkSession) { storeSavedToLinkMessage(manifest, selectedAccountIds.size) } }.onFailure { - if (!isRelink) { + if (!isNetworkingRelinkSession) { storeFailedToSaveToLinkMessage(selectedAccountIds.size) } }.getOrThrow() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt index f89c788287d..b1fba07d846 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt @@ -336,12 +336,12 @@ internal class AccountPickerViewModel @AssistedInject constructor( if (manifest.isDataFlow && manifest.canSaveAccountsToLink && consumerSessionClientSecret != null) { // In the data flow, we save accounts to Link in this screen. In the payment flow, // it happens in the AttachPaymentScreen. - val isRelink = pendingRepairRepository.get() != null + val isNetworkingRelinkSession = pendingRepairRepository.get() != null saveAccountToLink.existing( consumerSessionClientSecret = consumerSessionClientSecret, selectedAccounts = accountsList.data.toCachedPartnerAccounts(), shouldPollAccountNumbers = manifest.isDataFlow, - isRelink = isRelink, + isNetworkingRelinkSession = isNetworkingRelinkSession, ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt index de4d5364973..aa5c567ed80 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/common/SharedPartnerAuth.kt @@ -197,7 +197,7 @@ private fun SharedPartnerAuthBody( state.payload()?.let { LoadedContent( showInModal = inModal, - isRelinkSession = state.isRelinkSession, + isRelinkSession = state.isNetworkingRelinkSession, authenticationStatus = state.authenticationStatus, payload = it, onContinueClick = onContinueClick, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt index 8a92a7eb19a..ba53c5a7e91 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt @@ -148,7 +148,7 @@ internal class LinkSignupHandlerForNetworking @Inject constructor( consumerSessionClientSecret = signup.consumerSession.clientSecret, selectedAccounts = selectedAccounts, shouldPollAccountNumbers = manifest.isDataFlow, - isRelink = false, + isNetworkingRelinkSession = false, ) } else { // ** Legacy signup endpoint on unverified flows: 1 request ** diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt index 9e113edf9da..fecfd14600a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt @@ -150,7 +150,7 @@ internal class NetworkingSaveToLinkVerificationViewModel @AssistedInject constru consumerSessionClientSecret = payload.consumerSessionClientSecret, selectedAccounts = accounts, shouldPollAccountNumbers = manifest.isDataFlow, - isRelink = false, + isNetworkingRelinkSession = false, ) } .onSuccess { eventTracker.track(VerificationSuccess(PANE)) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt index 24f1d289f80..080f8696e87 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModel.kt @@ -117,6 +117,19 @@ internal class PartnerAuthViewModel @AssistedInject constructor( } } + private fun initializeState() { + suspend { + val sync = getOrFetchSync() + if (pane == Pane.BANK_AUTH_REPAIR) { + initializeBankAuthRepair(sync) + } else { + initializePartnerAuth(sync) + } + }.execute { + copy(payload = it) + } + } + private suspend fun initializeBankAuthRepair( sync: SynchronizeSessionResponse, ): Payload { @@ -147,19 +160,6 @@ internal class PartnerAuthViewModel @AssistedInject constructor( ) } - private fun initializeState() { - suspend { - val sync = getOrFetchSync() - if (pane == Pane.BANK_AUTH_REPAIR) { - initializeBankAuthRepair(sync) - } else { - initializePartnerAuth(sync) - } - }.execute { - copy(payload = it) - } - } - private fun recreateAuthSession() = suspend { val launchedEvent = Launched(Date()) val sync: SynchronizeSessionResponse = getOrFetchSync() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt index ece34d12ff6..6980fb46534 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt @@ -17,7 +17,7 @@ internal data class SharedPartnerAuthState( val inModal: Boolean = false, ) { - val isRelinkSession: Boolean + val isNetworkingRelinkSession: Boolean get() = pane == Pane.BANK_AUTH_REPAIR constructor(args: PartnerAuthViewModel.Args) : this( @@ -47,7 +47,7 @@ internal data class SharedPartnerAuthState( authenticationStatus !is Success && // Failures posting institution -> don't allow back navigation payload !is Fail && - !isRelinkSession + !isNetworkingRelinkSession sealed interface ViewEffect { data class OpenPartnerAuth( From 1555ddbaa6f8c028c1a0d030c7e9a2a81fdf1add Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 7 Feb 2025 11:54:22 -0500 Subject: [PATCH 06/10] Fix tests --- .../accountpicker/AccountPickerViewModelTest.kt | 10 ++++++++++ .../LinkSignupHandlerForNetworkingTest.kt | 2 +- ...etworkingSaveToLinkVerificationViewModelTest.kt | 5 ++++- .../partnerauth/PartnerAuthViewModelTest.kt | 9 ++++++++- .../partnerauth/SupportabilityViewModelTest.kt | 14 +++++++------- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt index 3ae7fb2521e..975266c21e4 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.features.accountpicker +import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures.authorizationSession @@ -23,11 +24,13 @@ import com.stripe.android.financialconnections.model.PartnerAccountsList import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.presentation.withState import com.stripe.android.financialconnections.repository.CachedConsumerSession +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.utils.TestNavigationManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -64,6 +67,10 @@ internal class AccountPickerViewModelTest { saveAccountToLink = saveAccountToLink, consumerSessionProvider = { cachedConsumerSession() }, presentSheet = mock(), + pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( + savedStateHandle = SavedStateHandle(), + logger = Logger.noop(), + ), ) @Test @@ -233,6 +240,7 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = any(), selectedAccounts = any(), shouldPollAccountNumbers = any(), + isNetworkingRelinkSession = eq(false), ) navigationManager.assertNavigatedTo( @@ -265,6 +273,7 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = any(), selectedAccounts = any(), shouldPollAccountNumbers = any(), + isNetworkingRelinkSession = eq(false), ) navigationManager.assertNavigatedTo( @@ -306,6 +315,7 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = consumerSession.clientSecret, selectedAccounts = accounts.data.map { CachedPartnerAccount(it.id, it.linkedAccountId) }, shouldPollAccountNumbers = true, + isNetworkingRelinkSession = eq(false), ) navigationManager.assertNavigatedTo( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt index 5af32ccb5d8..5be235ba6f2 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt @@ -104,7 +104,7 @@ class LinkSignupHandlerForNetworkingTest { verificationToken = eq(expectedToken), appId = eq("applicationId") ) - verify(saveAccountToLink).existing(any(), any(), any()) + verify(saveAccountToLink).existing(any(), any(), any(), any()) assertEquals(expectedPane, result) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt index c32f7fa174b..8d4c6f3b65a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt @@ -103,6 +103,7 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(listOf(selectedAccount)), eq(true), + eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, @@ -148,6 +149,7 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(emptyList()), eq(true), + eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, @@ -171,7 +173,7 @@ class NetworkingSaveToLinkVerificationViewModelTest { whenever(getOrFetchSync()).thenReturn(syncResponse(sessionManifest())) whenever(markLinkVerified()).thenReturn(linkVerifiedManifest) whenever(getCachedAccounts()).thenReturn(listOf(selectedAccount)) - whenever(saveAccountToLink.existing(any(), any(), any())).thenThrow(RuntimeException("error")) + whenever(saveAccountToLink.existing(any(), any(), any(), any())).thenThrow(RuntimeException("error")) val viewModel = buildViewModel() @@ -187,6 +189,7 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(listOf(selectedAccount)), eq(true), + eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt index d1f7ca7d993..76f550d05bd 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.features.partnerauth +import androidx.lifecycle.SavedStateHandle import com.stripe.android.core.Logger import com.stripe.android.financialconnections.CoroutineTestRule import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker @@ -10,6 +11,7 @@ import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager import com.stripe.android.financialconnections.navigation.PopUpToBehavior import com.stripe.android.financialconnections.presentation.Async +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,7 +53,7 @@ class PartnerAuthViewModelTest { val viewModel = makeViewModel( initialState = SharedPartnerAuthState( - pane = Pane.PARTNER_AUTH_DRAWER, + pane = Pane.PARTNER_AUTH, payload = Async.Uninitialized, inModal = false, ), @@ -97,6 +99,11 @@ class PartnerAuthViewModelTest { presentSheet = mock(), initialState = initialState, nativeAuthFlowCoordinator = NativeAuthFlowCoordinator(), + pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( + savedStateHandle = SavedStateHandle(), + logger = Logger.noop(), + ), + repairAuthSession = mock(), ) } } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt index f6d2e519c76..37a3c93f30d 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/SupportabilityViewModelTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.financialconnections.features.partnerauth +import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.core.exception.APIException @@ -24,11 +25,11 @@ import com.stripe.android.financialconnections.model.MixedOAuthParams import com.stripe.android.financialconnections.presentation.Async import com.stripe.android.financialconnections.presentation.WebAuthFlowState import com.stripe.android.financialconnections.presentation.withState +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.utils.TestHandleError import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -79,9 +80,6 @@ internal class SupportabilityViewModelTest { createViewModel() - // This is a bad workaround. Find something better! - advanceUntilIdle() - handleError.assertError( extraMessage = "Error fetching payload / posting AuthSession", error = unplannedDowntimeError, @@ -102,9 +100,6 @@ internal class SupportabilityViewModelTest { val viewModel = createViewModel(SharedPartnerAuthState(Pane.PARTNER_AUTH_DRAWER)) - // This is a bad workaround. Find something better! - advanceUntilIdle() - eventTracker.assertContainsEvent( "linked_accounts.pane.loaded", mapOf( @@ -325,6 +320,11 @@ internal class SupportabilityViewModelTest { applicationId = applicationId, nativeAuthFlowCoordinator = nativeAuthFlowCoordinator, presentSheet = mock(), + pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( + savedStateHandle = SavedStateHandle(), + logger = Logger.noop(), + ), + repairAuthSession = mock(), ) } } From ea50ed9f36693aaa168fb69bb0e67a34f89491b1 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Fri, 7 Feb 2025 12:19:33 -0500 Subject: [PATCH 07/10] Add unit tests --- .../api/financial-connections.api | 8 -- .../AccountPickerViewModelTest.kt | 2 +- .../partnerauth/PartnerAuthViewModelTest.kt | 101 ++++++++++++++++-- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/financial-connections/api/financial-connections.api b/financial-connections/api/financial-connections.api index 38e0aa54223..93f6e2d0e98 100644 --- a/financial-connections/api/financial-connections.api +++ b/financial-connections/api/financial-connections.api @@ -438,14 +438,6 @@ public final class com/stripe/android/financialconnections/features/attachpaymen public final fun getLambda-1$financial_connections_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel$Args$Creator : android/os/Parcelable$Creator { - public fun ()V - public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel$Args; - public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; - public final fun newArray (I)[Lcom/stripe/android/financialconnections/features/bankauthrepair/BankAuthRepairViewModel$Args; - public synthetic fun newArray (I)[Ljava/lang/Object; -} - public final class com/stripe/android/financialconnections/features/common/ComposableSingletons$AccountItemKt { public static final field INSTANCE Lcom/stripe/android/financialconnections/features/common/ComposableSingletons$AccountItemKt; public fun ()V diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt index 975266c21e4..810a4e6282a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt @@ -315,7 +315,7 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = consumerSession.clientSecret, selectedAccounts = accounts.data.map { CachedPartnerAccount(it.id, it.linkedAccountId) }, shouldPollAccountNumbers = true, - isNetworkingRelinkSession = eq(false), + isNetworkingRelinkSession = false, ) navigationManager.assertNavigatedTo( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt index 76f550d05bd..c6c17eace5a 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/partnerauth/PartnerAuthViewModelTest.kt @@ -2,10 +2,16 @@ package com.stripe.android.financialconnections.features.partnerauth import androidx.lifecycle.SavedStateHandle import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.ApiKeyFixtures.institution +import com.stripe.android.financialconnections.ApiKeyFixtures.sessionManifest +import com.stripe.android.financialconnections.ApiKeyFixtures.syncResponse import com.stripe.android.financialconnections.CoroutineTestRule import com.stripe.android.financialconnections.TestFinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker +import com.stripe.android.financialconnections.domain.GetOrFetchSync import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator +import com.stripe.android.financialconnections.domain.PostAuthorizationSession +import com.stripe.android.financialconnections.domain.RepairAuthorizationSession import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.navigation.Destination import com.stripe.android.financialconnections.navigation.NavigationManager @@ -15,9 +21,14 @@ import com.stripe.android.financialconnections.repository.CoreAuthorizationPendi import com.stripe.android.financialconnections.utils.TestNavigationManager import com.stripe.android.financialconnections.utils.UriUtils import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify @OptIn(ExperimentalCoroutinesApi::class) class PartnerAuthViewModelTest { @@ -25,6 +36,79 @@ class PartnerAuthViewModelTest { @get:Rule val testRule = CoroutineTestRule() + @Test + fun `Creates auth session when in partner auth flow`() = runTest { + val syncResponse = syncResponse( + manifest = sessionManifest().copy( + activeInstitution = institution(), + ) + ) + + val getOrFetchSync = mock { + onBlocking { invoke(any(), any()) } doReturn syncResponse + } + + val createAuthorizationSession = mock() + val repairAuthSession = mock() + + makeViewModel( + initialState = SharedPartnerAuthState( + pane = Pane.PARTNER_AUTH, + inModal = true, + ), + getOrFetchSync = getOrFetchSync, + createAuthorizationSession = createAuthorizationSession, + repairAuthSession = repairAuthSession, + ) + + verify(createAuthorizationSession).invoke( + institution = syncResponse.manifest.activeInstitution!!, + sync = syncResponse, + ) + + verify(repairAuthSession, never()).invoke(any()) + } + + @Test + fun `Creates repair session when in networking relink flow`() = runTest { + val syncResponse = syncResponse( + manifest = sessionManifest().copy( + activeInstitution = institution(), + ) + ) + + val createAuthorizationSession = mock() + val repairAuthSession = mock() + + val pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( + savedStateHandle = SavedStateHandle(), + logger = Logger.noop(), + ).apply { + set("fcauth_123") + } + + val getOrFetchSync = mock { + onBlocking { invoke(any(), any()) } doReturn syncResponse + } + + makeViewModel( + initialState = SharedPartnerAuthState( + pane = Pane.BANK_AUTH_REPAIR, + inModal = false, + ), + getOrFetchSync = getOrFetchSync, + createAuthorizationSession = createAuthorizationSession, + repairAuthSession = repairAuthSession, + pendingRepairRepository = pendingRepairRepository, + ) + + verify(repairAuthSession).invoke( + coreAuthorization = "fcauth_123", + ) + + verify(createAuthorizationSession, never()).invoke(any(), any()) + } + @Test fun `Navigates back when users cancels in modal`() { val navigationManager = TestNavigationManager() @@ -32,7 +116,7 @@ class PartnerAuthViewModelTest { val viewModel = makeViewModel( initialState = SharedPartnerAuthState( - pane = Pane.PARTNER_AUTH_DRAWER, + pane = Pane.PARTNER_AUTH, payload = Async.Uninitialized, inModal = true, ), @@ -77,10 +161,14 @@ class PartnerAuthViewModelTest { initialState: SharedPartnerAuthState, navigationManager: NavigationManager = TestNavigationManager(), tracker: FinancialConnectionsAnalyticsTracker = TestFinancialConnectionsAnalyticsTracker(), + getOrFetchSync: GetOrFetchSync = mock(), + createAuthorizationSession: PostAuthorizationSession = mock(), + repairAuthSession: RepairAuthorizationSession = mock(), + pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository = mock(), ): PartnerAuthViewModel { return PartnerAuthViewModel( completeAuthorizationSession = mock(), - createAuthorizationSession = mock(), + createAuthorizationSession = createAuthorizationSession, cancelAuthorizationSession = mock(), retrieveAuthorizationSession = mock(), eventTracker = tracker, @@ -90,7 +178,7 @@ class PartnerAuthViewModelTest { tracker = tracker, ), postAuthSessionEvent = mock(), - getOrFetchSync = mock(), + getOrFetchSync = getOrFetchSync, browserManager = mock(), handleError = mock(), navigationManager = navigationManager, @@ -99,11 +187,8 @@ class PartnerAuthViewModelTest { presentSheet = mock(), initialState = initialState, nativeAuthFlowCoordinator = NativeAuthFlowCoordinator(), - pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( - savedStateHandle = SavedStateHandle(), - logger = Logger.noop(), - ), - repairAuthSession = mock(), + pendingRepairRepository = pendingRepairRepository, + repairAuthSession = repairAuthSession, ) } } From 0854f7b22c926a34e0a3e12266f35f81776f0fc7 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Thu, 13 Feb 2025 10:40:04 -0500 Subject: [PATCH 08/10] Don't show custom success message in networking relink --- .../FinancialConnectionsSheetSharedModule.kt | 5 +++ .../domain/IsNetworkingRelinkSession.kt | 17 ++++++++++ .../domain/SaveAccountToLink.kt | 9 ++--- .../accountpicker/AccountPickerViewModel.kt | 4 --- .../attachpayment/AttachPaymentViewModel.kt | 34 +++++++++++++------ .../networkinglinksignup/LinkSignupHandler.kt | 1 - ...tworkingSaveToLinkVerificationViewModel.kt | 1 - .../domain/SaveAccountToLinkTest.kt | 33 ++++++++++++++++++ .../AccountPickerViewModelTest.kt | 4 --- .../LinkSignupHandlerForNetworkingTest.kt | 2 +- ...kingSaveToLinkVerificationViewModelTest.kt | 5 +-- 11 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 financial-connections/src/main/java/com/stripe/android/financialconnections/domain/IsNetworkingRelinkSession.kt diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt index 27ac5771936..d9681e43fc5 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/di/FinancialConnectionsSheetSharedModule.kt @@ -30,7 +30,9 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsAna import com.stripe.android.financialconnections.analytics.FinancialConnectionsEventReporter import com.stripe.android.financialconnections.domain.GetOrFetchSync import com.stripe.android.financialconnections.domain.IsLinkWithStripe +import com.stripe.android.financialconnections.domain.IsNetworkingRelinkSession import com.stripe.android.financialconnections.domain.RealIsLinkWithStripe +import com.stripe.android.financialconnections.domain.RealIsNetworkingRelinkSession import com.stripe.android.financialconnections.features.common.enableWorkManager import com.stripe.android.financialconnections.repository.ConsumerSessionProvider import com.stripe.android.financialconnections.repository.ConsumerSessionRepository @@ -86,6 +88,9 @@ internal interface FinancialConnectionsSheetSharedModule { @Binds fun bindsIsLinkWithStripe(impl: RealIsLinkWithStripe): IsLinkWithStripe + @Binds + fun bindsIsNetworkingRelinkSession(impl: RealIsNetworkingRelinkSession): IsNetworkingRelinkSession + companion object { @Provides diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/IsNetworkingRelinkSession.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/IsNetworkingRelinkSession.kt new file mode 100644 index 00000000000..cece794c769 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/IsNetworkingRelinkSession.kt @@ -0,0 +1,17 @@ +package com.stripe.android.financialconnections.domain + +import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository +import javax.inject.Inject + +internal fun interface IsNetworkingRelinkSession { + operator fun invoke(): Boolean +} + +internal class RealIsNetworkingRelinkSession @Inject constructor( + private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, +) : IsNetworkingRelinkSession { + + override fun invoke(): Boolean { + return pendingRepairRepository.get() != null + } +} diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt index cad867d1561..d09f7c80f30 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt @@ -24,6 +24,7 @@ internal class SaveAccountToLink @Inject constructor( private val successContentRepository: SuccessContentRepository, private val repository: FinancialConnectionsManifestRepository, private val accountsRepository: FinancialConnectionsAccountsRepository, + private val isNetworkingRelinkSession: IsNetworkingRelinkSession, ) { suspend fun new( @@ -35,7 +36,6 @@ internal class SaveAccountToLink @Inject constructor( ): FinancialConnectionsSessionManifest { return ensureReadyAccounts( shouldPollAccountNumbers = shouldPollAccountNumbers, - isNetworkingRelinkSession = false, partnerAccounts = selectedAccounts, ) { selectedAccountIds -> repository.postSaveAccountsToLink( @@ -54,11 +54,9 @@ internal class SaveAccountToLink @Inject constructor( consumerSessionClientSecret: String, selectedAccounts: List?, shouldPollAccountNumbers: Boolean, - isNetworkingRelinkSession: Boolean, ): FinancialConnectionsSessionManifest { return ensureReadyAccounts( shouldPollAccountNumbers = shouldPollAccountNumbers, - isNetworkingRelinkSession = isNetworkingRelinkSession, partnerAccounts = selectedAccounts, ) { selectedAccountIds -> repository.postSaveAccountsToLink( @@ -75,7 +73,6 @@ internal class SaveAccountToLink @Inject constructor( private suspend fun ensureReadyAccounts( shouldPollAccountNumbers: Boolean, - isNetworkingRelinkSession: Boolean, partnerAccounts: List?, action: suspend (Set?) -> FinancialConnectionsSessionManifest, ): FinancialConnectionsSessionManifest { @@ -100,11 +97,11 @@ internal class SaveAccountToLink @Inject constructor( }.mapCatching { action(selectedAccountIds) }.onSuccess { manifest -> - if (!isNetworkingRelinkSession) { + if (!isNetworkingRelinkSession()) { storeSavedToLinkMessage(manifest, selectedAccountIds.size) } }.onFailure { - if (!isNetworkingRelinkSession) { + if (!isNetworkingRelinkSession()) { storeFailedToSaveToLinkMessage(selectedAccountIds.size) } }.getOrThrow() diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt index b1fba07d846..be3ace3344a 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModel.kt @@ -48,7 +48,6 @@ import com.stripe.android.financialconnections.presentation.Async.Loading import com.stripe.android.financialconnections.presentation.Async.Uninitialized import com.stripe.android.financialconnections.presentation.FinancialConnectionsViewModel import com.stripe.android.financialconnections.repository.ConsumerSessionProvider -import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.ui.HandleClickableUrl import com.stripe.android.financialconnections.utils.error import com.stripe.android.financialconnections.utils.measureTimeMillis @@ -71,7 +70,6 @@ internal class AccountPickerViewModel @AssistedInject constructor( private val logger: Logger, private val pollAuthorizationSessionAccounts: PollAuthorizationSessionAccounts, private val presentSheet: PresentSheet, - private val pendingRepairRepository: CoreAuthorizationPendingNetworkingRepairRepository, ) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { init { @@ -336,12 +334,10 @@ internal class AccountPickerViewModel @AssistedInject constructor( if (manifest.isDataFlow && manifest.canSaveAccountsToLink && consumerSessionClientSecret != null) { // In the data flow, we save accounts to Link in this screen. In the payment flow, // it happens in the AttachPaymentScreen. - val isNetworkingRelinkSession = pendingRepairRepository.get() != null saveAccountToLink.existing( consumerSessionClientSecret = consumerSessionClientSecret, selectedAccounts = accountsList.data.toCachedPartnerAccounts(), shouldPollAccountNumbers = manifest.isDataFlow, - isNetworkingRelinkSession = isNetworkingRelinkSession, ) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt index ff09332237c..993465f3976 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/attachpayment/AttachPaymentViewModel.kt @@ -9,10 +9,13 @@ import com.stripe.android.financialconnections.analytics.FinancialConnectionsAna import com.stripe.android.financialconnections.analytics.FinancialConnectionsAnalyticsTracker import com.stripe.android.financialconnections.analytics.logError import com.stripe.android.financialconnections.di.FinancialConnectionsSheetNativeComponent +import com.stripe.android.financialconnections.domain.CachedPartnerAccount import com.stripe.android.financialconnections.domain.GetCachedAccounts import com.stripe.android.financialconnections.domain.GetOrFetchSync +import com.stripe.android.financialconnections.domain.IsNetworkingRelinkSession import com.stripe.android.financialconnections.domain.NativeAuthFlowCoordinator import com.stripe.android.financialconnections.domain.PollAttachPaymentAccount +import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest import com.stripe.android.financialconnections.model.FinancialConnectionsSessionManifest.Pane import com.stripe.android.financialconnections.model.LinkAccountSessionPaymentAccount import com.stripe.android.financialconnections.model.PaymentAccountParams @@ -41,7 +44,8 @@ internal class AttachPaymentViewModel @AssistedInject constructor( private val getCachedAccounts: GetCachedAccounts, private val navigationManager: NavigationManager, private val getOrFetchSync: GetOrFetchSync, - private val logger: Logger + private val logger: Logger, + private val isNetworkingRelinkSession: IsNetworkingRelinkSession, ) : FinancialConnectionsViewModel(initialState, nativeAuthFlowCoordinator) { init { @@ -60,15 +64,8 @@ internal class AttachPaymentViewModel @AssistedInject constructor( params = PaymentAccountParams.LinkedAccount(requireNotNull(id)) ) } - if (manifest.isNetworkingUserFlow == true && manifest.accountholderIsLinkConsumer == true) { - result.networkingSuccessful?.let { - successContentRepository.set( - message = PluralId( - value = R.plurals.stripe_success_pane_desc_link_success, - count = accounts.size - ) - ) - } + if (result.networkingSuccessful == true) { + setSuccessMessageIfNecessary(manifest, accounts) } eventTracker.track( PollAttachPaymentsSucceeded( @@ -91,6 +88,20 @@ internal class AttachPaymentViewModel @AssistedInject constructor( ) } + private fun setSuccessMessageIfNecessary( + manifest: FinancialConnectionsSessionManifest, + accounts: List, + ) { + if (manifest.canSetCustomLinkSuccessMessage && !isNetworkingRelinkSession()) { + successContentRepository.set( + message = PluralId( + value = R.plurals.stripe_success_pane_desc_link_success, + count = accounts.size + ) + ) + } + } + private fun logErrors() { onAsync( AttachPaymentState::linkPaymentAccount, @@ -132,3 +143,6 @@ internal class AttachPaymentViewModel @AssistedInject constructor( internal data class AttachPaymentState( val linkPaymentAccount: Async = Uninitialized ) + +private val FinancialConnectionsSessionManifest.canSetCustomLinkSuccessMessage: Boolean + get() = isNetworkingUserFlow == true && accountholderIsLinkConsumer == true diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt index ba53c5a7e91..4a93b0266ff 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandler.kt @@ -148,7 +148,6 @@ internal class LinkSignupHandlerForNetworking @Inject constructor( consumerSessionClientSecret = signup.consumerSession.clientSecret, selectedAccounts = selectedAccounts, shouldPollAccountNumbers = manifest.isDataFlow, - isNetworkingRelinkSession = false, ) } else { // ** Legacy signup endpoint on unverified flows: 1 request ** diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt index fecfd14600a..fecc8a28712 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModel.kt @@ -150,7 +150,6 @@ internal class NetworkingSaveToLinkVerificationViewModel @AssistedInject constru consumerSessionClientSecret = payload.consumerSessionClientSecret, selectedAccounts = accounts, shouldPollAccountNumbers = manifest.isDataFlow, - isNetworkingRelinkSession = false, ) } .onSuccess { eventTracker.track(VerificationSuccess(PANE)) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/SaveAccountToLinkTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/SaveAccountToLinkTest.kt index 7814d935a9b..4307593cd50 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/SaveAccountToLinkTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/domain/SaveAccountToLinkTest.kt @@ -179,11 +179,43 @@ internal class SaveAccountToLinkTest { ) } + @Test + fun `Doesn't set success message if in networking relink session`() = runTest(testDispatcher) { + val accountsRepository = mockAccountsRepository() + val attachedPaymentAccountRepository = mock() + val successRepository = SuccessContentRepository(SavedStateHandle()) + + whenever(attachedPaymentAccountRepository.get()).thenReturn( + State( + attachedPaymentAccount = PaymentAccountParams.BankAccount( + accountNumber = "acct_123", + routingNumber = "110000000", + ) + ) + ) + + val saveAccountToLink = makeSaveAccountToLink( + accountsRepository = accountsRepository, + successRepository = successRepository, + attachedPaymentAccountRepository = attachedPaymentAccountRepository, + isNetworkingRelinkSession = { true }, + ) + + saveAccountToLink.existing( + consumerSessionClientSecret = "cscs_123", + selectedAccounts = emptyList(), + shouldPollAccountNumbers = true, + ) + + assertThat(successRepository.get()).isNull() + } + private fun makeSaveAccountToLink( repository: FinancialConnectionsManifestRepository = mockManifestRepository(), accountsRepository: FinancialConnectionsAccountsRepository = mockAccountsRepository(), successRepository: SuccessContentRepository = SuccessContentRepository(SavedStateHandle()), attachedPaymentAccountRepository: AttachedPaymentAccountRepository = mock(), + isNetworkingRelinkSession: IsNetworkingRelinkSession = IsNetworkingRelinkSession { false }, ): SaveAccountToLink { return SaveAccountToLink( locale = Locale.getDefault(), @@ -195,6 +227,7 @@ internal class SaveAccountToLinkTest { repository = repository, attachedPaymentAccountRepository = attachedPaymentAccountRepository, accountsRepository = accountsRepository, + isNetworkingRelinkSession = isNetworkingRelinkSession, ) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt index 810a4e6282a..cea445ff8d8 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -240,7 +239,6 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = any(), selectedAccounts = any(), shouldPollAccountNumbers = any(), - isNetworkingRelinkSession = eq(false), ) navigationManager.assertNavigatedTo( @@ -273,7 +271,6 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = any(), selectedAccounts = any(), shouldPollAccountNumbers = any(), - isNetworkingRelinkSession = eq(false), ) navigationManager.assertNavigatedTo( @@ -315,7 +312,6 @@ internal class AccountPickerViewModelTest { consumerSessionClientSecret = consumerSession.clientSecret, selectedAccounts = accounts.data.map { CachedPartnerAccount(it.id, it.linkedAccountId) }, shouldPollAccountNumbers = true, - isNetworkingRelinkSession = false, ) navigationManager.assertNavigatedTo( diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt index 5be235ba6f2..5af32ccb5d8 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkinglinksignup/LinkSignupHandlerForNetworkingTest.kt @@ -104,7 +104,7 @@ class LinkSignupHandlerForNetworkingTest { verificationToken = eq(expectedToken), appId = eq("applicationId") ) - verify(saveAccountToLink).existing(any(), any(), any(), any()) + verify(saveAccountToLink).existing(any(), any(), any()) assertEquals(expectedPane, result) } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt index 8d4c6f3b65a..c32f7fa174b 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/networkingsavetolinkverification/NetworkingSaveToLinkVerificationViewModelTest.kt @@ -103,7 +103,6 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(listOf(selectedAccount)), eq(true), - eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, @@ -149,7 +148,6 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(emptyList()), eq(true), - eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, @@ -173,7 +171,7 @@ class NetworkingSaveToLinkVerificationViewModelTest { whenever(getOrFetchSync()).thenReturn(syncResponse(sessionManifest())) whenever(markLinkVerified()).thenReturn(linkVerifiedManifest) whenever(getCachedAccounts()).thenReturn(listOf(selectedAccount)) - whenever(saveAccountToLink.existing(any(), any(), any(), any())).thenThrow(RuntimeException("error")) + whenever(saveAccountToLink.existing(any(), any(), any())).thenThrow(RuntimeException("error")) val viewModel = buildViewModel() @@ -189,7 +187,6 @@ class NetworkingSaveToLinkVerificationViewModelTest { eq(state.payload()!!.consumerSessionClientSecret), eq(listOf(selectedAccount)), eq(true), - eq(false), ) verify(confirmVerification).sms( consumerSessionClientSecret = cachedConsumerSession.clientSecret, From a5cc2dff731ec68324773938e3c697c89c4ab16f Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Thu, 13 Feb 2025 11:48:51 -0500 Subject: [PATCH 09/10] Clean up --- .../financialconnections/domain/SaveAccountToLink.kt | 10 ++-------- .../FinancialConnectionsManifestRepository.kt | 7 +++++-- .../accountpicker/AccountPickerViewModelTest.kt | 6 ------ 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt index d09f7c80f30..0044dd45b03 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/domain/SaveAccountToLink.kt @@ -34,10 +34,7 @@ internal class SaveAccountToLink @Inject constructor( country: String, shouldPollAccountNumbers: Boolean, ): FinancialConnectionsSessionManifest { - return ensureReadyAccounts( - shouldPollAccountNumbers = shouldPollAccountNumbers, - partnerAccounts = selectedAccounts, - ) { selectedAccountIds -> + return ensureReadyAccounts(shouldPollAccountNumbers, selectedAccounts) { selectedAccountIds -> repository.postSaveAccountsToLink( clientSecret = configuration.financialConnectionsSessionClientSecret, email = email, @@ -55,10 +52,7 @@ internal class SaveAccountToLink @Inject constructor( selectedAccounts: List?, shouldPollAccountNumbers: Boolean, ): FinancialConnectionsSessionManifest { - return ensureReadyAccounts( - shouldPollAccountNumbers = shouldPollAccountNumbers, - partnerAccounts = selectedAccounts, - ) { selectedAccountIds -> + return ensureReadyAccounts(shouldPollAccountNumbers, selectedAccounts) { selectedAccountIds -> repository.postSaveAccountsToLink( clientSecret = configuration.financialConnectionsSessionClientSecret, email = null, diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt index ce4c0429952..e44d616f0fe 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/repository/FinancialConnectionsManifestRepository.kt @@ -359,7 +359,7 @@ private class FinancialConnectionsManifestRepositoryImpl( ): FinancialConnectionsAuthorizationSession { val repairSession = requestExecutor.execute( request = apiRequestFactory.createPost( - url = "${ApiRequest.API_HOST}/v1/connections/repair_sessions/generate_url", + url = generateRepairUrl, options = provideApiRequestOptions(useConsumerPublishableKey = true), params = mapOf( NetworkConstants.PARAMS_CLIENT_SECRET to clientSecret, @@ -376,7 +376,7 @@ private class FinancialConnectionsManifestRepositoryImpl( flow = repairSession.flow, display = repairSession.display, _isOAuth = repairSession.isOAuth, - nextPane = Pane.SUCCESS, // TODO + nextPane = Pane.SUCCESS, ).also { updateCachedActiveAuthSession("repairAuthorizationSession", it) } @@ -598,5 +598,8 @@ private class FinancialConnectionsManifestRepositoryImpl( internal const val disableNetworking: String = "${ApiRequest.API_HOST}/v1/link_account_sessions/disable_networking" + + internal const val generateRepairUrl: String = + "${ApiRequest.API_HOST}/v1/connections/repair_sessions/generate_url" } } diff --git a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt index cea445ff8d8..3ae7fb2521e 100644 --- a/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt +++ b/financial-connections/src/test/java/com/stripe/android/financialconnections/features/accountpicker/AccountPickerViewModelTest.kt @@ -1,6 +1,5 @@ package com.stripe.android.financialconnections.features.accountpicker -import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.financialconnections.ApiKeyFixtures.authorizationSession @@ -24,7 +23,6 @@ import com.stripe.android.financialconnections.model.PartnerAccountsList import com.stripe.android.financialconnections.navigation.destination import com.stripe.android.financialconnections.presentation.withState import com.stripe.android.financialconnections.repository.CachedConsumerSession -import com.stripe.android.financialconnections.repository.CoreAuthorizationPendingNetworkingRepairRepository import com.stripe.android.financialconnections.utils.TestNavigationManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -66,10 +64,6 @@ internal class AccountPickerViewModelTest { saveAccountToLink = saveAccountToLink, consumerSessionProvider = { cachedConsumerSession() }, presentSheet = mock(), - pendingRepairRepository = CoreAuthorizationPendingNetworkingRepairRepository( - savedStateHandle = SavedStateHandle(), - logger = Logger.noop(), - ), ) @Test From 6656e2eafe47bd8b8d6904e4a2c159301c7cb93d Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Tue, 18 Feb 2025 19:44:05 -0500 Subject: [PATCH 10/10] Address code review feedback - Consolidate some `else` branches - Add comment --- .../AccountUpdateRequiredViewModel.kt | 30 ++++++++----------- .../partnerauth/SharedPartnerAuthState.kt | 1 + 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt index df0cd57acb0..85e2243d46e 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/accountupdate/AccountUpdateRequiredViewModel.kt @@ -62,11 +62,7 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( val referrer = state.referrer when (val type = requireNotNull(state.payload()?.type)) { is Type.Repair -> { - if (type.authorization != null) { - openBankAuthRepair(type.institution, type.authorization, referrer) - } else { - handleUnsupportedRepairAction(referrer) - } + openBankAuthRepair(type.institution, type.authorization, referrer) } is Type.Supportability -> { openPartnerAuth(type.institution, referrer) @@ -75,23 +71,12 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( } } - private fun handleUnsupportedRepairAction(referrer: Pane) { - eventTracker.logError( - extraMessage = "Updating a repair account, but repairs are not supported in Mobile.", - error = UnclassifiedError("UpdateRepairAccountError"), - logger = logger, - pane = PANE, - ) - // Fall back to the institution picker for now - navigationManager.tryNavigateTo(InstitutionPicker(referrer)) - } - private fun openBankAuthRepair( institution: FinancialConnectionsInstitution?, - authorization: String, + authorization: String?, referrer: Pane, ) { - if (institution != null) { + if (institution != null && authorization != null) { updateLocalManifest { it.copy(activeInstitution = institution) } @@ -99,6 +84,15 @@ internal class AccountUpdateRequiredViewModel @AssistedInject constructor( pendingRepairRepository.set(authorization) navigationManager.tryNavigateTo(Destination.BankAuthRepair(referrer)) } else { + val missingAuth = authorization == null + val missingInstitution = institution == null + eventTracker.logError( + extraMessage = "Unable to open repair flow " + + "(missing auth: $missingAuth, missing institution: $missingInstitution).", + error = UnclassifiedError("UpdateRepairAccountError"), + logger = logger, + pane = PANE, + ) // Fall back to the institution picker navigationManager.tryNavigateTo(InstitutionPicker(referrer)) } diff --git a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt index 6980fb46534..1f7bb320ce8 100644 --- a/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthState.kt @@ -47,6 +47,7 @@ internal data class SharedPartnerAuthState( authenticationStatus !is Success && // Failures posting institution -> don't allow back navigation payload !is Fail && + // No back navigation after creating the new auth session in the relink flow !isNetworkingRelinkSession sealed interface ViewEffect {