From 0f90bbeb28c2c8c527b8511314d009d02a3e6f00 Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Tue, 17 Dec 2024 12:37:56 -0600 Subject: [PATCH] Save confirmation state to SavedStateHandle. (#9794) --- paymentsheet/api/paymentsheet.api | 8 ++ .../paymentelement/EmbeddedPaymentElement.kt | 2 +- .../embedded/EmbeddedConfirmationHelper.kt | 26 ++---- .../EmbeddedConfirmationStateHolder.kt | 54 ++++++++++++ .../embedded/SharedPaymentElementViewModel.kt | 9 +- .../EmbeddedConfirmationHelperTest.kt | 6 +- .../EmbeddedConfirmationStateHolderTest.kt | 87 +++++++++++++++++++ .../SharedPaymentElementViewModelTest.kt | 43 +++++---- 8 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder.kt create mode 100644 paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolderTest.kt diff --git a/paymentsheet/api/paymentsheet.api b/paymentsheet/api/paymentsheet.api index 17588487b17..1844548c8c6 100644 --- a/paymentsheet/api/paymentsheet.api +++ b/paymentsheet/api/paymentsheet.api @@ -428,6 +428,14 @@ public final class com/stripe/android/paymentelement/embedded/DefaultEmbeddedCon public synthetic fun newArray (I)[Ljava/lang/Object; } +public final class com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder$State$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder$State; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder$State; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class com/stripe/android/paymentsheet/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt index efb6402e3e3..a26523c62af 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt @@ -471,7 +471,7 @@ class EmbeddedPaymentElement private constructor( resultCallback = resultCallback, activityResultCaller = activityResultCaller, lifecycleOwner = lifecycleOwner, - confirmationStateSupplier = { sharedViewModel.confirmationState } + confirmationStateSupplier = { sharedViewModel.confirmationStateHolder.state }, ), sharedViewModel = sharedViewModel, ) diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelper.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelper.kt index b74c88febc8..48fb20a2231 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelper.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelper.kt @@ -4,13 +4,10 @@ import androidx.activity.result.ActivityResultCaller import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.stripe.android.common.model.asCommonConfiguration -import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata import com.stripe.android.paymentelement.EmbeddedPaymentElement import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi import com.stripe.android.paymentelement.confirmation.ConfirmationHandler import com.stripe.android.paymentelement.confirmation.toConfirmationOption -import com.stripe.android.paymentsheet.model.PaymentSelection -import com.stripe.android.paymentsheet.state.PaymentElementLoader import kotlinx.coroutines.launch @ExperimentalEmbeddedPaymentElementApi @@ -19,7 +16,7 @@ internal class EmbeddedConfirmationHelper( private val resultCallback: EmbeddedPaymentElement.ResultCallback, private val activityResultCaller: ActivityResultCaller, private val lifecycleOwner: LifecycleOwner, - private val confirmationStateSupplier: () -> State?, + private val confirmationStateSupplier: () -> EmbeddedConfirmationStateHolder.State?, ) { init { confirmationHandler.register( @@ -50,25 +47,18 @@ internal class EmbeddedConfirmationHelper( } private fun confirmationArgs(): ConfirmationHandler.Args? { - val loadedState = confirmationStateSupplier() ?: return null - val confirmationOption = loadedState.selection?.toConfirmationOption( - configuration = loadedState.configuration.asCommonConfiguration(), + val confirmationState = confirmationStateSupplier() ?: return null + val confirmationOption = confirmationState.selection?.toConfirmationOption( + configuration = confirmationState.configuration.asCommonConfiguration(), linkConfiguration = null, ) ?: return null return ConfirmationHandler.Args( - intent = loadedState.paymentMethodMetadata.stripeIntent, + intent = confirmationState.paymentMethodMetadata.stripeIntent, confirmationOption = confirmationOption, - initializationMode = loadedState.initializationMode, - appearance = loadedState.configuration.appearance, - shippingDetails = loadedState.configuration.shippingDetails, + initializationMode = confirmationState.initializationMode, + appearance = confirmationState.configuration.appearance, + shippingDetails = confirmationState.configuration.shippingDetails, ) } - - data class State( - val paymentMethodMetadata: PaymentMethodMetadata, - val selection: PaymentSelection?, - val initializationMode: PaymentElementLoader.InitializationMode, - val configuration: EmbeddedPaymentElement.Configuration, - ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder.kt new file mode 100644 index 00000000000..943d757616e --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolder.kt @@ -0,0 +1,54 @@ +package com.stripe.android.paymentelement.embedded + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi +import com.stripe.android.paymentsheet.model.PaymentSelection +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@AssistedFactory +@ExperimentalEmbeddedPaymentElementApi +internal fun interface EmbeddedConfirmationStateHolderFactory { + fun create(coroutineScope: CoroutineScope): EmbeddedConfirmationStateHolder +} + +@ExperimentalEmbeddedPaymentElementApi +internal class EmbeddedConfirmationStateHolder @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, + private val selectionHolder: EmbeddedSelectionHolder, + @Assisted coroutineScope: CoroutineScope, +) { + var state: State? + get() = savedStateHandle[CONFIRMATION_STATE_KEY] + set(value) { + savedStateHandle[CONFIRMATION_STATE_KEY] = value + } + + init { + coroutineScope.launch { + selectionHolder.selection.collect { selection -> + state = state?.copy(selection = selection) + } + } + } + + @Parcelize + data class State( + val paymentMethodMetadata: PaymentMethodMetadata, + val selection: PaymentSelection?, + val initializationMode: PaymentElementLoader.InitializationMode, + val configuration: EmbeddedPaymentElement.Configuration, + ) : Parcelable + + companion object { + const val CONFIRMATION_STATE_KEY = "CONFIRMATION_STATE_KEY" + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt index dba593b0c8f..3aab9865674 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModel.kt @@ -74,8 +74,10 @@ import javax.inject.Singleton import kotlin.coroutines.CoroutineContext import kotlin.reflect.KClass +@Singleton @OptIn(ExperimentalEmbeddedPaymentElementApi::class) internal class SharedPaymentElementViewModel @Inject constructor( + confirmationStateHolderFactory: EmbeddedConfirmationStateHolderFactory, confirmationHandlerFactory: ConfirmationHandler.Factory, @IOContext ioContext: CoroutineContext, private val configurationHandler: EmbeddedConfigurationHandler, @@ -85,19 +87,16 @@ internal class SharedPaymentElementViewModel @Inject constructor( private val _paymentOption: MutableStateFlow = MutableStateFlow(null) val paymentOption: StateFlow = _paymentOption.asStateFlow() + val confirmationStateHolder = confirmationStateHolderFactory.create(viewModelScope) val confirmationHandler = confirmationHandlerFactory.create(viewModelScope + ioContext) private val _embeddedContent = MutableStateFlow(null) val embeddedContent: StateFlow = _embeddedContent.asStateFlow() - @Volatile - var confirmationState: EmbeddedConfirmationHelper.State? = null - init { viewModelScope.launch { selectionHolder.selection.collect { selection -> _paymentOption.value = paymentOptionDisplayDataFactory.create(selection) - confirmationState = confirmationState?.copy(selection = selection) } } } @@ -111,7 +110,7 @@ internal class SharedPaymentElementViewModel @Inject constructor( configuration = configuration, ).fold( onSuccess = { state -> - confirmationState = EmbeddedConfirmationHelper.State( + confirmationStateHolder.state = EmbeddedConfirmationStateHolder.State( paymentMethodMetadata = state.paymentMethodMetadata, selection = state.paymentSelection, initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent(intentConfiguration), diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelperTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelperTest.kt index c25763c3ca8..44a477f360e 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelperTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationHelperTest.kt @@ -73,8 +73,8 @@ internal class EmbeddedConfirmationHelperTest { assertThat(confirmationHandler.startTurbine.awaitItem()).isNotNull() } - private fun defaultLoadedState(): EmbeddedConfirmationHelper.State { - return EmbeddedConfirmationHelper.State( + private fun defaultLoadedState(): EmbeddedConfirmationStateHolder.State { + return EmbeddedConfirmationStateHolder.State( paymentMethodMetadata = PaymentMethodMetadataFactory.create(), selection = PaymentSelection.GooglePay, initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( @@ -97,7 +97,7 @@ internal class EmbeddedConfirmationHelperTest { } private fun testScenario( - loadedState: EmbeddedConfirmationHelper.State? = defaultLoadedState(), + loadedState: EmbeddedConfirmationStateHolder.State? = defaultLoadedState(), block: suspend Scenario.() -> Unit, ) = runTest { val resultCallbackTurbine = Turbine() diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolderTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolderTest.kt new file mode 100644 index 00000000000..4267d0002cf --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/EmbeddedConfirmationStateHolderTest.kt @@ -0,0 +1,87 @@ +@file:OptIn(ExperimentalEmbeddedPaymentElementApi::class) + +package com.stripe.android.paymentelement.embedded + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadataFactory +import com.stripe.android.paymentelement.EmbeddedPaymentElement +import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi +import com.stripe.android.paymentelement.embedded.EmbeddedConfirmationStateHolder.Companion.CONFIRMATION_STATE_KEY +import com.stripe.android.paymentelement.embedded.EmbeddedConfirmationStateHolder.State +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.model.PaymentSelection +import com.stripe.android.paymentsheet.model.paymentMethodType +import com.stripe.android.paymentsheet.state.PaymentElementLoader +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class EmbeddedConfirmationStateHolderTest { + @Test + fun `setting state updates savedStateHandle`() = testScenario { + assertThat(savedStateHandle.get(CONFIRMATION_STATE_KEY)).isNull() + val state = defaultState() + confirmationStateHolder.state = state + assertThat(savedStateHandle.get(CONFIRMATION_STATE_KEY)).isEqualTo(state) + } + + @Test + fun `initializing with state in savedStateHandle sets initial value`() { + val state = defaultState() + testScenario( + setup = { + set(CONFIRMATION_STATE_KEY, state) + }, + ) { + assertThat(savedStateHandle.get(CONFIRMATION_STATE_KEY)).isEqualTo(state) + confirmationStateHolder.state = null + assertThat(savedStateHandle.get(CONFIRMATION_STATE_KEY)).isNull() + } + } + + @Test + fun `updating selection updates state with selection`() = testScenario { + confirmationStateHolder.state = defaultState() + assertThat(confirmationStateHolder.state?.selection?.paymentMethodType).isNull() + selectionHolder.set(PaymentSelection.GooglePay) + assertThat(confirmationStateHolder.state?.selection?.paymentMethodType).isEqualTo("google_pay") + } + + private fun defaultState(): State = State( + paymentMethodMetadata = PaymentMethodMetadataFactory.create(), + selection = null, + initializationMode = PaymentElementLoader.InitializationMode.DeferredIntent( + intentConfiguration = PaymentSheet.IntentConfiguration( + mode = PaymentSheet.IntentConfiguration.Mode.Payment(amount = 5000, currency = "USD"), + ) + ), + configuration = EmbeddedPaymentElement.Configuration.Builder("Example, Inc.").build() + ) + + private class Scenario( + val selectionHolder: EmbeddedSelectionHolder, + val confirmationStateHolder: EmbeddedConfirmationStateHolder, + val savedStateHandle: SavedStateHandle, + ) + + private fun testScenario( + setup: SavedStateHandle.() -> Unit = {}, + block: suspend Scenario.() -> Unit, + ) = runTest { + val savedStateHandle = SavedStateHandle() + setup(savedStateHandle) + val selectionHolder = EmbeddedSelectionHolder(savedStateHandle) + val confirmationStateHolder = EmbeddedConfirmationStateHolder( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ) + Scenario( + selectionHolder = selectionHolder, + confirmationStateHolder = confirmationStateHolder, + savedStateHandle = savedStateHandle, + ).block() + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt index f7c5bbb363c..d664302d318 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/SharedPaymentElementViewModelTest.kt @@ -17,6 +17,8 @@ import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.paymentMethodType import com.stripe.android.paymentsheet.state.PaymentElementLoader import com.stripe.android.ui.core.cbc.CardBrandChoiceEligibility +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -52,7 +54,7 @@ internal class SharedPaymentElementViewModelTest { ) ) - assertThat(viewModel.confirmationState).isNull() + assertThat(viewModel.confirmationStateHolder.state).isNull() assertThat( viewModel.configure( @@ -63,7 +65,7 @@ internal class SharedPaymentElementViewModelTest { ) ).isInstanceOf() - assertThat(viewModel.confirmationState?.paymentMethodMetadata).isNotNull() + assertThat(viewModel.confirmationStateHolder.state?.paymentMethodMetadata).isNotNull() } @Test @@ -91,7 +93,7 @@ internal class SharedPaymentElementViewModelTest { ) ) - assertThat(viewModel.confirmationState?.selection?.paymentMethodType).isNull() + assertThat(viewModel.confirmationStateHolder.state?.selection?.paymentMethodType).isNull() assertThat( viewModel.configure( @@ -102,9 +104,9 @@ internal class SharedPaymentElementViewModelTest { ) ).isInstanceOf() - assertThat(viewModel.confirmationState?.selection?.paymentMethodType).isEqualTo("google_pay") + assertThat(viewModel.confirmationStateHolder.state?.selection?.paymentMethodType).isEqualTo("google_pay") selectionHolder.set(null) - assertThat(viewModel.confirmationState?.selection?.paymentMethodType).isNull() + assertThat(viewModel.confirmationStateHolder.state?.selection?.paymentMethodType).isNull() } @Test @@ -205,16 +207,25 @@ internal class SharedPaymentElementViewModelTest { private fun testScenario( block: suspend Scenario.() -> Unit, ) { - val confirmationHandler = FakeConfirmationHandler() - val configurationHandler = FakeEmbeddedConfigurationHandler() - val paymentOptionDisplayDataFactory = PaymentOptionDisplayDataFactory( - iconLoader = mock(), - context = ApplicationProvider.getApplicationContext(), - ) - val selectionHolder = EmbeddedSelectionHolder(SavedStateHandle()) - runTest { + val confirmationHandler = FakeConfirmationHandler() + val configurationHandler = FakeEmbeddedConfigurationHandler() + val paymentOptionDisplayDataFactory = PaymentOptionDisplayDataFactory( + iconLoader = mock(), + context = ApplicationProvider.getApplicationContext(), + ) + val savedStateHandle = SavedStateHandle() + val selectionHolder = EmbeddedSelectionHolder(savedStateHandle) + val confirmationStateHolder = EmbeddedConfirmationStateHolder( + savedStateHandle = savedStateHandle, + selectionHolder = selectionHolder, + coroutineScope = CoroutineScope(UnconfinedTestDispatcher()), + ) + val viewModel = SharedPaymentElementViewModel( + confirmationStateHolderFactory = EmbeddedConfirmationStateHolderFactory { + confirmationStateHolder + }, confirmationHandlerFactory = { confirmationHandler }, ioContext = testScheduler, configurationHandler = configurationHandler, @@ -227,10 +238,10 @@ internal class SharedPaymentElementViewModelTest { viewModel = viewModel, selectionHolder = selectionHolder, ).block() - } - configurationHandler.turbine.ensureAllEventsConsumed() - confirmationHandler.validate() + configurationHandler.turbine.ensureAllEventsConsumed() + confirmationHandler.validate() + } } private class Scenario(