-
Notifications
You must be signed in to change notification settings - Fork 728
Introduce CheckoutController state holders and state loader with tests. #13398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.stripe.android.checkout | ||
|
|
||
| import android.os.Parcelable | ||
| import androidx.lifecycle.SavedStateHandle | ||
| import com.stripe.android.core.injection.ViewModelScope | ||
| import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata | ||
| import com.stripe.android.paymentelement.EmbeddedPaymentElement | ||
| import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder | ||
| import com.stripe.android.paymentsheet.model.PaymentSelection | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.parcelize.Parcelize | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| /** | ||
| * Owns the confirmation [State] for [CheckoutController], persisting it in [SavedStateHandle] so it | ||
| * survives process death. Kept separate from the controller so the confirmation flow can depend on | ||
| * this holder directly rather than reaching back into the controller. | ||
| */ | ||
| @Singleton | ||
| internal class CheckoutConfirmationStateHolder @Inject constructor( | ||
| private val savedStateHandle: SavedStateHandle, | ||
| private val selectionHolder: EmbeddedSelectionHolder, | ||
| @ViewModelScope private val coroutineScope: CoroutineScope, | ||
| ) { | ||
| var state: State? | ||
| get() = savedStateHandle[CONFIRMATION_STATE_KEY] | ||
| set(value) { | ||
| savedStateHandle[CONFIRMATION_STATE_KEY] = value | ||
| } | ||
|
|
||
| val stateFlow: StateFlow<State?> | ||
| get() = savedStateHandle.getStateFlow(CONFIRMATION_STATE_KEY, null) | ||
|
|
||
| init { | ||
| coroutineScope.launch { | ||
| selectionHolder.selection.collect { selection -> | ||
| state = state?.copy(selection = selection) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Parcelize | ||
| data class State( | ||
| val paymentMethodMetadata: PaymentMethodMetadata, | ||
| val selection: PaymentSelection?, | ||
| val configuration: EmbeddedPaymentElement.Configuration, | ||
| ) : Parcelable | ||
|
|
||
| companion object { | ||
| const val CONFIRMATION_STATE_KEY = "CheckoutController_ConfirmationState" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| package com.stripe.android.checkout | ||
|
|
||
| import android.graphics.Bitmap | ||
| import android.os.Parcelable | ||
| import com.stripe.android.paymentelement.CheckoutSessionPreview | ||
| import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse | ||
| import com.stripe.android.paymentsheet.state.PaymentElementLoader | ||
| import kotlinx.parcelize.Parcelize | ||
| import java.util.UUID | ||
|
|
||
| /** | ||
| * Internal state for [CheckoutController]. Unlike [InternalState] (used by [Checkout]), this holds | ||
| * the controller's own [CheckoutController.Configuration.State] directly, so the configuration | ||
| * doesn't have to be reconstructed and can be read back via [configuration]. | ||
| */ | ||
| @OptIn(CheckoutSessionPreview::class) | ||
| @Parcelize | ||
| internal data class CheckoutControllerState( | ||
| val key: String, | ||
| val configuration: CheckoutController.Configuration.State, | ||
| override val checkoutSessionResponse: CheckoutSessionResponse, | ||
| val flagImages: Map<String, Bitmap>?, | ||
| override val shippingName: String?, | ||
| override val billingName: String?, | ||
| override val shippingPhoneNumber: String?, | ||
| override val billingPhoneNumber: String?, | ||
| override val shippingAddress: Address.State?, | ||
| override val billingAddress: Address.State?, | ||
| val integrationLaunched: Boolean, | ||
| ) : Parcelable, CheckoutSessionData { | ||
| val initializationMode: PaymentElementLoader.InitializationMode.CheckoutSession | ||
| get() = PaymentElementLoader.InitializationMode.CheckoutSession( | ||
| instancesKey = key, | ||
| checkoutSessionResponse = checkoutSessionResponse, | ||
| ) | ||
|
|
||
| fun asCheckoutSession(): CheckoutSession { | ||
| return checkoutSessionResponse.asCheckoutSession(flagImages) | ||
| } | ||
|
|
||
| companion object { | ||
| /** | ||
| * Builds the initial state for a freshly configured checkout session: a new [key], the | ||
| * given [configuration] and [checkoutSessionResponse], and no resolved flag images or | ||
| * collected shipping/billing details yet. | ||
| */ | ||
| fun defaultState( | ||
| configuration: CheckoutController.Configuration.State, | ||
| checkoutSessionResponse: CheckoutSessionResponse, | ||
| ): CheckoutControllerState = CheckoutControllerState( | ||
| key = UUID.randomUUID().toString(), | ||
| configuration = configuration, | ||
| checkoutSessionResponse = checkoutSessionResponse, | ||
| flagImages = null, | ||
| shippingName = null, | ||
| billingName = null, | ||
| shippingPhoneNumber = null, | ||
| billingPhoneNumber = null, | ||
| shippingAddress = null, | ||
| billingAddress = null, | ||
| integrationLaunched = false, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.stripe.android.checkout | ||
|
|
||
| import androidx.lifecycle.SavedStateHandle | ||
| import com.stripe.android.paymentelement.CheckoutSessionPreview | ||
| import com.stripe.android.uicore.utils.mapAsStateFlow | ||
| import kotlinx.coroutines.flow.StateFlow | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| /** | ||
| * Owns [CheckoutController]'s [CheckoutControllerState], persisting it in [SavedStateHandle] so it | ||
| * survives process death, and derives the observable [checkoutSession] from it. Kept separate from | ||
| * the controller so [CheckoutStateLoader] can commit loaded state directly rather than reaching back | ||
| * into the controller. | ||
| */ | ||
| @OptIn(CheckoutSessionPreview::class) | ||
| @Singleton | ||
| internal class CheckoutControllerStateHolder @Inject constructor( | ||
| private val savedStateHandle: SavedStateHandle, | ||
| ) { | ||
| var state: CheckoutControllerState? | ||
| get() = savedStateHandle[STATE_KEY] | ||
| set(value) { | ||
| savedStateHandle[STATE_KEY] = value | ||
| } | ||
|
|
||
| val checkoutSession: StateFlow<CheckoutSession?> = | ||
| savedStateHandle.getStateFlow<CheckoutControllerState?>(STATE_KEY, null) | ||
| .mapAsStateFlow { it?.asCheckoutSession() } | ||
|
|
||
| companion object { | ||
| const val STATE_KEY = "CheckoutController_InternalState" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package com.stripe.android.checkout | ||
|
|
||
| import com.stripe.android.checkout.injection.MerchantDisplayName | ||
| import com.stripe.android.common.model.asCommonConfiguration | ||
| import com.stripe.android.paymentelement.CheckoutSessionPreview | ||
| import com.stripe.android.paymentelement.EmbeddedPaymentElement | ||
| import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder | ||
| import com.stripe.android.paymentelement.embedded.content.EmbeddedSelectionChooser | ||
| import com.stripe.android.paymentsheet.state.PaymentElementLoader | ||
| import javax.inject.Inject | ||
|
|
||
| /** | ||
| * Loads the payment element for a [CheckoutControllerState] and, on success, atomically commits it | ||
| * to the [stateHolder], [confirmationStateHolder], and [selectionHolder]. Extracted from | ||
| * [CheckoutController] so the load-and-commit flow can be unit tested in isolation from the | ||
| * controller. | ||
| */ | ||
| @OptIn(CheckoutSessionPreview::class) | ||
| internal class CheckoutStateLoader @Inject constructor( | ||
| @MerchantDisplayName private val merchantDisplayName: String, | ||
| private val flagImageResolver: FlagImageResolver, | ||
| private val paymentElementLoader: PaymentElementLoader, | ||
| private val selectionChooser: EmbeddedSelectionChooser, | ||
| private val selectionHolder: EmbeddedSelectionHolder, | ||
| private val confirmationStateHolder: CheckoutConfirmationStateHolder, | ||
| private val stateHolder: CheckoutControllerStateHolder, | ||
| ) { | ||
| /** | ||
| * Loads the payment element for [state] and, on success, atomically commits the resolved state. | ||
| * Throws if the load fails, leaving the previously committed state untouched (the commits are | ||
| * the final step, so a failure short-circuits before any holder is mutated). | ||
| */ | ||
| suspend fun load(state: CheckoutControllerState) { | ||
| val response = state.checkoutSessionResponse | ||
|
|
||
| val resolvedState = state.copy( | ||
| // [state] carries the previously resolved flag images forward (a mutation copies them | ||
| // from the committed state), so they're reused when the currencies haven't changed. | ||
| flagImages = flagImageResolver.resolve(response, cached = state.flagImages), | ||
| ) | ||
|
Comment on lines
+36
to
+40
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it feels surprising that we have to make a copy of state here. Does this mean that the state in CheckoutController is out of sync with what we use in
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For configure this is a little silly. But I built it to be prepared for mutations, which is where this is actually useful. During a mutation, we'll have the previously configured response + flags, etc -- and we want all the same state, except for the new checkout sessions response + the updated flags (if they're needed). |
||
|
|
||
| val baseConfig = EmbeddedPaymentElement.Configuration.Builder(merchantDisplayName) | ||
| .embeddedViewDisplaysMandateText( | ||
| resolvedState.configuration.paymentElementConfiguration.embeddedViewDisplaysMandateText | ||
| ) | ||
| .build() | ||
|
|
||
| val embeddedConfig = CheckoutConfigurationMerger.EmbeddedConfiguration(baseConfig) | ||
| .forCheckoutSession(resolvedState) | ||
|
|
||
| val loaderState = paymentElementLoader.load( | ||
| initializationMode = resolvedState.initializationMode, | ||
| integrationConfiguration = PaymentElementLoader.Configuration.Embedded( | ||
| isRowSelectionImmediateAction = false, | ||
| configuration = embeddedConfig, | ||
| ), | ||
| metadata = PaymentElementLoader.Metadata( | ||
| isReloadingAfterProcessDeath = false, | ||
| initializedViaCompose = false, | ||
| ), | ||
| ).getOrThrow() | ||
|
|
||
| // Preserve the customer's existing selection across reloads when it's still valid, rather | ||
| // than blindly adopting the loader's recomputed selection (reuses the embedded logic). | ||
| val selection = selectionChooser.choose( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this logic live in PaymentElementLoader?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could probably inject it. But it does seem logical that it would go there 🤔 I'll attempt this in a follow up!
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds good! Right now I think PaymentElementLoader is doing some work and then we throw out the results of that work |
||
| paymentMethodMetadata = loaderState.paymentMethodMetadata, | ||
| paymentMethods = loaderState.customer?.paymentMethods, | ||
| previousSelection = selectionHolder.selection.value, | ||
| newSelection = loaderState.paymentSelection, | ||
| newConfiguration = embeddedConfig.asCommonConfiguration(), | ||
| formSheetAction = embeddedConfig.formSheetAction, | ||
| ) | ||
|
|
||
| stateHolder.state = resolvedState | ||
| confirmationStateHolder.state = CheckoutConfirmationStateHolder.State( | ||
| paymentMethodMetadata = loaderState.paymentMethodMetadata, | ||
| selection = selection, | ||
| configuration = embeddedConfig, | ||
| ) | ||
| selectionHolder.set(selection) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OOC why do we not use the checkout session response ID for this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't think of a great reason not to use it.