From 2c010b4ead4540cb5becdcaea369d47feea6da31 Mon Sep 17 00:00:00 2001 From: Till Hellmund Date: Wed, 22 Jan 2025 15:43:22 -0500 Subject: [PATCH] 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 | 468 ++--------------- .../partnerauth/SharedPartnerAuthState.kt | 6 +- .../partnerauth/SharedPartnerAuthViewModel.kt | 485 ++++++++++++++++++ .../model/AuthorizationRepairResponse.kt | 15 + ...zationPendingNetworkingRepairRepository.kt | 4 +- .../FinancialConnectionsManifestRepository.kt | 38 ++ 14 files changed, 743 insertions(+), 473 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 af4dcaabe35..435be1a3173 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, @@ -282,6 +286,7 @@ private fun PrePaneContent( status = authenticationStatus, oAuthPrepane = content, showInModal = showInModal, + showSecondaryButton = showSecondaryButton, ) } ) @@ -356,6 +361,7 @@ private fun PrepaneFooter( status: Async, oAuthPrepane: OauthPrepane, showInModal: Boolean, + showSecondaryButton: Boolean, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -389,25 +395,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 9fe794da252..f49f9997e11 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 @@ -376,6 +376,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 cd8d9674d0c..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,476 +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.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.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() = 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) - } - navigationManager.tryNavigateBack() } @Parcelize @@ -488,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..541b30ead70 --- /dev/null +++ b/financial-connections/src/main/java/com/stripe/android/financialconnections/features/partnerauth/SharedPartnerAuthViewModel.kt @@ -0,0 +1,485 @@ +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.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 + +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(200) + + 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() { + 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) + } + withState { state -> + 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 7ff344ea0ce..408462c17cc 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,