Skip to content

Commit

Permalink
Save confirmation state to SavedStateHandle. (#9794)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaynewstrom-stripe authored Dec 17, 2024
1 parent b1d4116 commit 0f90bbe
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 43 deletions.
8 changes: 8 additions & 0 deletions paymentsheet/api/paymentsheet.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ class EmbeddedPaymentElement private constructor(
resultCallback = resultCallback,
activityResultCaller = activityResultCaller,
lifecycleOwner = lifecycleOwner,
confirmationStateSupplier = { sharedViewModel.confirmationState }
confirmationStateSupplier = { sharedViewModel.confirmationStateHolder.state },
),
sharedViewModel = sharedViewModel,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -85,19 +87,16 @@ internal class SharedPaymentElementViewModel @Inject constructor(
private val _paymentOption: MutableStateFlow<PaymentOptionDisplayData?> = MutableStateFlow(null)
val paymentOption: StateFlow<PaymentOptionDisplayData?> = _paymentOption.asStateFlow()

val confirmationStateHolder = confirmationStateHolderFactory.create(viewModelScope)
val confirmationHandler = confirmationHandlerFactory.create(viewModelScope + ioContext)

private val _embeddedContent = MutableStateFlow<EmbeddedContent?>(null)
val embeddedContent: StateFlow<EmbeddedContent?> = _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)
}
}
}
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<EmbeddedPaymentElement.Result>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<State?>(CONFIRMATION_STATE_KEY)).isNull()
val state = defaultState()
confirmationStateHolder.state = state
assertThat(savedStateHandle.get<State?>(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<State?>(CONFIRMATION_STATE_KEY)).isEqualTo(state)
confirmationStateHolder.state = null
assertThat(savedStateHandle.get<State?>(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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,7 +54,7 @@ internal class SharedPaymentElementViewModelTest {
)
)

assertThat(viewModel.confirmationState).isNull()
assertThat(viewModel.confirmationStateHolder.state).isNull()

assertThat(
viewModel.configure(
Expand All @@ -63,7 +65,7 @@ internal class SharedPaymentElementViewModelTest {
)
).isInstanceOf<EmbeddedPaymentElement.ConfigureResult.Succeeded>()

assertThat(viewModel.confirmationState?.paymentMethodMetadata).isNotNull()
assertThat(viewModel.confirmationStateHolder.state?.paymentMethodMetadata).isNotNull()
}

@Test
Expand Down Expand Up @@ -91,7 +93,7 @@ internal class SharedPaymentElementViewModelTest {
)
)

assertThat(viewModel.confirmationState?.selection?.paymentMethodType).isNull()
assertThat(viewModel.confirmationStateHolder.state?.selection?.paymentMethodType).isNull()

assertThat(
viewModel.configure(
Expand All @@ -102,9 +104,9 @@ internal class SharedPaymentElementViewModelTest {
)
).isInstanceOf<EmbeddedPaymentElement.ConfigureResult.Succeeded>()

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
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down

0 comments on commit 0f90bbe

Please sign in to comment.