diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/Checkout.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/Checkout.kt index 7edd6c0d6ac..1f6f7b27210 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/Checkout.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/Checkout.kt @@ -409,10 +409,6 @@ class Checkout private constructor( */ val isLoading: StateFlow = _isLoading.asStateFlow() - init { - CheckoutInstances.add(internalState.key, this) - } - /** * Applies a promotion code to the checkout session. * diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutInstances.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutInstances.kt index 2470219a6ee..91d214d62a8 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutInstances.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutInstances.kt @@ -1,57 +1,40 @@ package com.stripe.android.checkout +import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting -import com.stripe.android.lpmfoundations.paymentmethod.IntegrationMetadata -import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata import com.stripe.android.paymentelement.CheckoutSessionPreview -import java.lang.ref.WeakReference @OptIn(CheckoutSessionPreview::class) internal object CheckoutInstances { - private val instanceMap = mutableMapOf>>() - - operator fun get(key: String): List { - val refs = instanceMap[key] ?: return emptyList() - val live = refs.mapNotNull { it.get() } - if (live.isEmpty()) { - instanceMap.remove(key) - } else if (live.size != refs.size) { - // Prune stale references - refs.clear() - refs.addAll(live.map { WeakReference(it) }) - } - return live - } - fun ensureNoMutationInFlight(key: String) { - this[key].forEach { it.ensureNoMutationInFlight() } - } + private data class Entry(val checkout: Checkout, val owner: Any) - fun markIntegrationLaunched(key: String) { - this[key].forEach { it.markIntegrationLaunched() } - } + private val instances = mutableMapOf() - fun markIntegrationDismissed(key: String) { - this[key].forEach { it.markIntegrationDismissed() } - } + @MainThread + operator fun get(key: String): Checkout? = instances[key]?.checkout - fun markIntegrationDismissed(paymentMethodMetadata: PaymentMethodMetadata?) { - val checkoutSession = paymentMethodMetadata - ?.integrationMetadata as? IntegrationMetadata.CheckoutSession ?: return - markIntegrationDismissed(checkoutSession.instancesKey) - } - - fun add(key: String, checkout: Checkout) { - val refs = instanceMap.getOrPut(key) { mutableListOf() } - refs.add(WeakReference(checkout)) + @MainThread + fun register(key: String, checkout: Checkout, owner: Any) { + val existing = instances[key] + if (existing != null) { + check(existing.checkout === checkout && existing.owner === owner) { + "Checkout already registered by a different owner. " + + "Only one integration can use a Checkout at a time." + } + } + instances[key] = Entry(checkout, owner) } - fun remove(key: String) { - instanceMap.remove(key) + @MainThread + fun unregister(key: String, checkout: Checkout) { + if (instances[key]?.checkout === checkout) { + instances.remove(key) + } } @VisibleForTesting fun clear() { - instanceMap.clear() + instances.clear() } } 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 aecf725d253..e8a66627485 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/EmbeddedPaymentElement.kt @@ -18,7 +18,6 @@ import com.stripe.android.ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi import com.stripe.android.SharedPaymentTokenSessionPreview import com.stripe.android.checkout.Checkout import com.stripe.android.checkout.CheckoutConfigurationMerger -import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.common.configuration.ConfigurationDefaults import com.stripe.android.common.ui.DelegateDrawable import com.stripe.android.core.utils.StatusBarCompat @@ -27,6 +26,7 @@ import com.stripe.android.model.PaymentIntent import com.stripe.android.model.PaymentMethod import com.stripe.android.model.SetupIntent import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder +import com.stripe.android.paymentelement.embedded.content.EmbeddedCheckoutRegistrar import com.stripe.android.paymentelement.embedded.content.EmbeddedConfigurationCoordinator import com.stripe.android.paymentelement.embedded.content.EmbeddedConfirmationHelper import com.stripe.android.paymentelement.embedded.content.EmbeddedConfirmationStateHolder @@ -59,6 +59,7 @@ class EmbeddedPaymentElement @Inject internal constructor( paymentOptionDisplayDataHolder: PaymentOptionDisplayDataHolder, private val configurationCoordinator: EmbeddedConfigurationCoordinator, stateHelper: EmbeddedStateHelper, + private val checkoutRegistrar: EmbeddedCheckoutRegistrar, ) { /** @@ -102,7 +103,8 @@ class EmbeddedPaymentElement @Inject internal constructor( checkout: Checkout, configuration: Configuration, ): ConfigureResult { - CheckoutInstances.ensureNoMutationInFlight(checkout.internalState.key) + checkout.ensureNoMutationInFlight() + checkoutRegistrar.register(checkout) return configurationCoordinator.configure( configuration = CheckoutConfigurationMerger.EmbeddedConfiguration(configuration) .forCheckoutSession(checkout.internalState), diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactory.kt index 144e0c68d26..56b866017be 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactory.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactory.kt @@ -14,8 +14,9 @@ internal object GooglePayDisplayItemsFactory { val checkoutSessionMetadata = paymentMethodMetadata.integrationMetadata as? IntegrationMetadata.CheckoutSession ?: return emptyList() - val checkout = CheckoutInstances[checkoutSessionMetadata.instancesKey].firstOrNull() - ?: return emptyList() + val checkout = requireNotNull(CheckoutInstances[checkoutSessionMetadata.instancesKey]) { + "Checkout not registered for key '${checkoutSessionMetadata.instancesKey}'." + } val checkoutSession = checkout.checkoutSession.value val items = mutableListOf() diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptor.kt index 42c932785b4..1901a287dd0 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptor.kt @@ -117,7 +117,10 @@ internal class CheckoutSessionConfirmationInterceptor @AssistedInject constructo params: ConfirmCheckoutSessionParams, ): ConfirmationDefinition.Action { try { - CheckoutInstances.ensureNoMutationInFlight(integrationMetadata.instancesKey) + val checkout = requireNotNull(CheckoutInstances[integrationMetadata.instancesKey]) { + "Checkout not registered for key '${integrationMetadata.instancesKey}'." + } + checkout.ensureNoMutationInFlight() } catch (e: IllegalStateException) { return ConfirmationDefinition.Action.Fail( cause = e, @@ -143,9 +146,10 @@ internal class CheckoutSessionConfirmationInterceptor @AssistedInject constructo private fun handleConfirmResponse( response: CheckoutSessionResponse, ): ConfirmationDefinition.Action { - CheckoutInstances[integrationMetadata.instancesKey].forEach { checkout -> - checkout.updateWithResponse(response) + val checkout = requireNotNull(CheckoutInstances[integrationMetadata.instancesKey]) { + "Checkout not registered for key '${integrationMetadata.instancesKey}'." } + checkout.updateWithResponse(response) val intent: StripeIntent = response.paymentIntent ?: response.setupIntent ?: run { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncher.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncher.kt index 92833ca06df..ed820979385 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncher.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncher.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentelement.embedded.content import androidx.activity.result.ActivityResultCaller @@ -119,8 +121,11 @@ internal class DefaultEmbeddedSheetLauncher @Inject constructor( ) { val checkoutSession = paymentMethodMetadata.integrationMetadata as? IntegrationMetadata.CheckoutSession if (checkoutSession != null) { - CheckoutInstances.ensureNoMutationInFlight(checkoutSession.instancesKey) - CheckoutInstances.markIntegrationLaunched(checkoutSession.instancesKey) + val checkout = requireNotNull(CheckoutInstances[checkoutSession.instancesKey]) { + "Checkout not registered for key '${checkoutSession.instancesKey}'." + } + checkout.ensureNoMutationInFlight() + checkout.markIntegrationLaunched() } if (embeddedConfirmationState == null) { errorReporter.report( @@ -156,8 +161,11 @@ internal class DefaultEmbeddedSheetLauncher @Inject constructor( ) { val checkoutSession = paymentMethodMetadata.integrationMetadata as? IntegrationMetadata.CheckoutSession if (checkoutSession != null) { - CheckoutInstances.ensureNoMutationInFlight(checkoutSession.instancesKey) - CheckoutInstances.markIntegrationLaunched(checkoutSession.instancesKey) + val checkout = requireNotNull(CheckoutInstances[checkoutSession.instancesKey]) { + "Checkout not registered for key '${checkoutSession.instancesKey}'." + } + checkout.ensureNoMutationInFlight() + checkout.markIntegrationLaunched() } if (embeddedConfirmationState == null) { errorReporter.report( diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedCheckoutRegistrar.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedCheckoutRegistrar.kt new file mode 100644 index 00000000000..792bb425d1e --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedCheckoutRegistrar.kt @@ -0,0 +1,37 @@ +package com.stripe.android.paymentelement.embedded.content + +import com.stripe.android.checkout.Checkout +import com.stripe.android.checkout.CheckoutInstances +import com.stripe.android.paymentelement.CheckoutSessionPreview +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Holds and manages the [Checkout] registration for the lifetime of one ViewModel-scoped + * [EmbeddedPaymentElement]. + * + * Registration happens in [EmbeddedPaymentElement.configure], and unregistration + * happens when the owning ViewModel is cleared via [unregisterIfNeeded]. + */ +@OptIn(CheckoutSessionPreview::class) +@Singleton +internal class EmbeddedCheckoutRegistrar @Inject constructor() { + + @Volatile + private var registeredCheckout: Checkout? = null + + fun register(checkout: Checkout) { + val previous = registeredCheckout + if (previous != null && previous !== checkout) { + CheckoutInstances.unregister(previous.internalState.key, previous) + } + CheckoutInstances.register(checkout.internalState.key, checkout, owner = this) + registeredCheckout = checkout + } + + fun unregisterIfNeeded() { + val checkout = registeredCheckout ?: return + CheckoutInstances.unregister(checkout.internalState.key, checkout) + registeredCheckout = null + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedPaymentElementViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedPaymentElementViewModel.kt index 44f5399adf8..600ede134cc 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedPaymentElementViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/content/EmbeddedPaymentElementViewModel.kt @@ -17,6 +17,7 @@ import kotlin.reflect.KClass internal class EmbeddedPaymentElementViewModel @Inject constructor( val embeddedPaymentElementSubcomponentFactory: EmbeddedPaymentElementSubcomponent.Factory, @ViewModelScope private val customViewModelScope: CoroutineScope, + private val checkoutRegistrar: EmbeddedCheckoutRegistrar, eventReporter: EventReporter, ) : ViewModel() { init { @@ -24,6 +25,7 @@ internal class EmbeddedPaymentElementViewModel @Inject constructor( } override fun onCleared() { + checkoutRegistrar.unregisterIfNeeded() customViewModelScope.cancel() } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivity.kt index 174f7d26d33..42e73bda59c 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/form/FormActivity.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentelement.embedded.form import android.app.Activity @@ -13,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.lifecycle.lifecycleScope import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.common.ui.BottomSheetScaffold +import com.stripe.android.lpmfoundations.paymentmethod.IntegrationMetadata import com.stripe.android.common.ui.ElementsBottomSheetLayout import com.stripe.android.paymentelement.embedded.sheet.EmbeddedNavigator import com.stripe.android.paymentelement.embedded.sheet.SheetActivityRegistrar @@ -145,7 +148,12 @@ internal class FormActivity : AppCompatActivity() { super.onDestroy() if (isFinishing) { - CheckoutInstances.markIntegrationDismissed(args?.paymentMethodMetadata) + val key = (args?.paymentMethodMetadata?.integrationMetadata + as? IntegrationMetadata.CheckoutSession)?.instancesKey + if (key != null) { + CheckoutInstances[key]?.markIntegrationDismissed() + // No unregister: EmbeddedPaymentElement owns the registration lifetime. + } if (::eventReporter.isInitialized) { eventReporter.onDismiss() } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivity.kt index 5abbc65e8d7..9325b3f37de 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivity.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentelement.embedded.sheet import android.app.Activity @@ -24,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.common.ui.BottomSheetScaffold +import com.stripe.android.lpmfoundations.paymentmethod.IntegrationMetadata import com.stripe.android.common.ui.ElementsBottomSheetLayout import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder import com.stripe.android.paymentsheet.CustomerStateHolder @@ -192,7 +195,12 @@ internal class EmbeddedSheetActivity : AppCompatActivity() { super.onDestroy() if (isFinishing) { - CheckoutInstances.markIntegrationDismissed(args?.paymentMethodMetadata) + val key = (args?.paymentMethodMetadata?.integrationMetadata + as? IntegrationMetadata.CheckoutSession)?.instancesKey + if (key != null) { + CheckoutInstances[key]?.markIntegrationDismissed() + // No unregister: EmbeddedPaymentElement owns the registration lifetime. + } if (::eventReporter.isInitialized) { eventReporter.onDismiss() } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt index 74ea1a8aab9..9adbe107b9e 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentsheet import android.content.Intent @@ -11,6 +13,7 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.ViewModelProvider import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.common.ui.ElementsBottomSheetLayout +import com.stripe.android.lpmfoundations.paymentmethod.IntegrationMetadata import com.stripe.android.paymentsheet.ui.BaseSheetActivity import com.stripe.android.paymentsheet.ui.PaymentSheetScreen import com.stripe.android.paymentsheet.utils.applicationIsTaskOwner @@ -86,7 +89,12 @@ internal class PaymentOptionsActivity : BaseSheetActivity() { } } - override fun onDestroy() { - super.onDestroy() - if (isFinishing && starterArgs != null) { - CheckoutInstances.markIntegrationDismissed(viewModel.paymentMethodMetadata.value) - } - } override fun setActivityResult(result: PaymentSheetResult) { setResult( diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowController.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowController.kt index 68c69b7fc57..10e001144af 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowController.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentsheet.flowcontroller import android.app.Activity @@ -119,6 +121,9 @@ internal class DefaultFlowController @Inject internal constructor( private val paymentOptionActivityLauncher: ActivityResultLauncher private val sepaMandateActivityLauncher: ActivityResultLauncher + @Volatile + private var registeredCheckout: Checkout? = null + /** * [FlowControllerComponent] is hold to inject into [Activity]s and created * after [DefaultFlowController]. @@ -171,6 +176,9 @@ internal class DefaultFlowController @Inject internal constructor( walletsButtonLinkLauncher.unregister() flowControllerLinkLauncher.unregister() PaymentElementCallbackReferences.remove(paymentElementCallbackIdentifier) + registeredCheckout?.let { + CheckoutInstances.unregister(it.internalState.key, it) + } } } ) @@ -243,7 +251,13 @@ internal class DefaultFlowController @Inject internal constructor( configuration: PaymentSheet.Configuration, callback: PaymentSheet.FlowController.ConfigCallback ) { - CheckoutInstances.ensureNoMutationInFlight(checkout.internalState.key) + checkout.ensureNoMutationInFlight() + val previous = registeredCheckout + if (previous != null && previous !== checkout) { + CheckoutInstances.unregister(previous.internalState.key, previous) + } + CheckoutInstances.register(checkout.internalState.key, checkout, owner = this) + registeredCheckout = checkout configure( mode = checkout.internalState.initializationMode, configuration = CheckoutConfigurationMerger.PaymentSheetConfiguration(configuration) @@ -305,8 +319,11 @@ internal class DefaultFlowController @Inject internal constructor( val checkoutSession = state.paymentSheetState.paymentMethodMetadata .integrationMetadata as? IntegrationMetadata.CheckoutSession if (checkoutSession != null) { - CheckoutInstances.ensureNoMutationInFlight(checkoutSession.instancesKey) - CheckoutInstances.markIntegrationLaunched(checkoutSession.instancesKey) + val checkout = requireNotNull(CheckoutInstances[checkoutSession.instancesKey]) { + "Checkout not registered. Call configureWithCheckout first." + } + checkout.ensureNoMutationInFlight() + checkout.markIntegrationLaunched() } val linkConfiguration = state.paymentSheetState.linkConfiguration diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdater.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdater.kt deleted file mode 100644 index dc411dd9857..00000000000 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdater.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.stripe.android.paymentsheet.verticalmode - -import com.stripe.android.checkout.CheckoutInstances -import com.stripe.android.paymentelement.CheckoutSessionPreview -import com.stripe.android.paymentsheet.PaymentSheet -import com.stripe.android.paymentsheet.repositories.CheckoutSessionRepository -import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse -import com.stripe.android.paymentsheet.state.PaymentElementLoader -import javax.inject.Inject - -internal interface CheckoutCurrencyUpdater { - suspend fun updateCurrency( - instancesKey: String, - sessionId: String, - currencyCode: String, - config: PaymentSheet.Configuration, - initializedViaCompose: Boolean, - ): Result - - data class CurrencyUpdateResult( - val checkoutSessionResponse: CheckoutSessionResponse, - val loaderState: PaymentElementLoader.State, - ) -} - -@OptIn(CheckoutSessionPreview::class) -internal class DefaultCheckoutCurrencyUpdater @Inject constructor( - private val checkoutSessionRepository: CheckoutSessionRepository, - private val paymentElementLoader: PaymentElementLoader, -) : CheckoutCurrencyUpdater { - - override suspend fun updateCurrency( - instancesKey: String, - sessionId: String, - currencyCode: String, - config: PaymentSheet.Configuration, - initializedViaCompose: Boolean, - ): Result { - val responseResult = checkoutSessionRepository.updateCurrency( - sessionId = sessionId, - currencyCode = currencyCode, - ) - - val response = responseResult.getOrElse { return Result.failure(it) } - - CheckoutInstances[instancesKey].forEach { it.updateWithResponse(response) } - - val newInitMode = PaymentElementLoader.InitializationMode.CheckoutSession( - instancesKey = instancesKey, - checkoutSessionResponse = response, - ) - - val loaderResult = paymentElementLoader.load( - initializationMode = newInitMode, - integrationConfiguration = PaymentElementLoader.Configuration.PaymentSheet(config), - metadata = PaymentElementLoader.Metadata( - isReloadingAfterProcessDeath = false, - initializedViaCompose = initializedViaCompose, - ), - ) - - val loaderState = loaderResult.getOrElse { return Result.failure(it) } - - return Result.success( - CheckoutCurrencyUpdater.CurrencyUpdateResult( - checkoutSessionResponse = response, - loaderState = loaderState, - ) - ) - } -} diff --git a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTest.kt b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTest.kt index 6426ceb4ea1..63d268c5aa4 100644 --- a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTest.kt @@ -3,14 +3,8 @@ package com.stripe.android.checkout import android.app.Application import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat -import com.stripe.android.checkouttesting.checkoutUpdate -import com.stripe.android.networktesting.NetworkRule import com.stripe.android.paymentelement.CheckoutSessionPreview import com.stripe.android.testing.PaymentConfigurationTestRule -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertThrows import org.junit.Rule @@ -18,20 +12,17 @@ import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @OptIn(CheckoutSessionPreview::class) @RunWith(RobolectricTestRunner::class) class CheckoutInstancesTest { private val applicationContext = ApplicationProvider.getApplicationContext() - private val networkRule = NetworkRule() + private val owner = Object() @get:Rule val ruleChain: RuleChain = RuleChain - .outerRule(networkRule) - .around(PaymentConfigurationTestRule(applicationContext)) + .outerRule(PaymentConfigurationTestRule(applicationContext)) @After fun tearDown() { @@ -39,152 +30,112 @@ class CheckoutInstancesTest { } @Test - fun `get returns empty list for unknown key`() { - assertThat(CheckoutInstances["unknown-key"]).isEmpty() + fun `get returns null for unknown key`() { + assertThat(CheckoutInstances["unknown-key"]).isNull() } @Test - fun `add and get round-trips single instance`() { + fun `register and get round-trips single instance`() { val checkout = createCheckout(key = "key1") - CheckoutInstances.clear() - CheckoutInstances.add("key1", checkout) + CheckoutInstances.register("key1", checkout, owner) - assertThat(CheckoutInstances["key1"]).containsExactly(checkout) + assertThat(CheckoutInstances["key1"]).isSameInstanceAs(checkout) } @Test - fun `add multiple instances with same key returns all`() { - val checkout1 = createCheckout(key = "key1") - val checkout2 = createCheckout(key = "key1") - CheckoutInstances.clear() - - CheckoutInstances.add("key1", checkout1) - CheckoutInstances.add("key1", checkout2) - - assertThat(CheckoutInstances["key1"]).containsExactly(checkout1, checkout2) - } - - @Test - fun `remove clears all instances for a key`() { + fun `register same instance twice is a no-op`() { val checkout = createCheckout(key = "key1") - CheckoutInstances.clear() - CheckoutInstances.add("key1", checkout) - assertThat(CheckoutInstances["key1"]).isNotEmpty() + CheckoutInstances.register("key1", checkout, owner) + CheckoutInstances.register("key1", checkout, owner) - CheckoutInstances.remove("key1") - assertThat(CheckoutInstances["key1"]).isEmpty() + assertThat(CheckoutInstances["key1"]).isSameInstanceAs(checkout) } @Test - fun `clear empties the map`() { + fun `register different instance for same key throws`() { val checkout1 = createCheckout(key = "key1") - val checkout2 = createCheckout(key = "key2") - CheckoutInstances.clear() - - CheckoutInstances.add("key1", checkout1) - CheckoutInstances.add("key2", checkout2) + val checkout2 = createCheckout(key = "key1") - CheckoutInstances.clear() + CheckoutInstances.register("key1", checkout1, owner) - assertThat(CheckoutInstances["key1"]).isEmpty() - assertThat(CheckoutInstances["key2"]).isEmpty() - } - - @Test - fun `ensureNoMutationInFlight does not throw for unknown key`() { - CheckoutInstances.ensureNoMutationInFlight("unknown-key") + val error = assertThrows(IllegalStateException::class.java) { + CheckoutInstances.register("key1", checkout2, owner) + } + assertThat(error).hasMessageThat().contains("different owner") } @Test - fun `ensureNoMutationInFlight throws when mutation is in flight`() { + fun `register same instance with different owner throws`() { val checkout = createCheckout(key = "key1") - val requestArrived = CountDownLatch(1) - val holdResponse = CountDownLatch(1) + val otherOwner = Object() - networkRule.checkoutUpdate { response -> - requestArrived.countDown() - holdResponse.await() - response.setBody("{}") - } + CheckoutInstances.register("key1", checkout, owner) - runBlocking { - val job = launch(Dispatchers.IO) { - checkout.removePromotionCode() - } + val error = assertThrows(IllegalStateException::class.java) { + CheckoutInstances.register("key1", checkout, otherOwner) + } + assertThat(error).hasMessageThat().contains("different owner") + } - assertThat(requestArrived.await(5, TimeUnit.SECONDS)).isTrue() + @Test + fun `unregister removes correct instance`() { + val checkout = createCheckout(key = "key1") + CheckoutInstances.register("key1", checkout, owner) - val error = assertThrows(IllegalStateException::class.java) { - CheckoutInstances.ensureNoMutationInFlight("key1") - } - assertThat(error).hasMessageThat() - .isEqualTo("Cannot launch while a checkout session mutation is in flight.") + CheckoutInstances.unregister("key1", checkout) - holdResponse.countDown() - job.join() - } + assertThat(CheckoutInstances["key1"]).isNull() } @Test - fun `markIntegrationLaunched marks all instances for a key`() = runTest { + fun `unregister with wrong instance is a no-op`() { val checkout1 = createCheckout(key = "key1") val checkout2 = createCheckout(key = "key1") - CheckoutInstances.clear() - CheckoutInstances.add("key1", checkout1) - CheckoutInstances.add("key1", checkout2) - - CheckoutInstances.markIntegrationLaunched("key1") - - // Both instances should return failure because integrationLaunched is set. - val result1 = checkout1.applyPromotionCode("code") - assertThat(result1.isFailure).isTrue() - assertThat(result1.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) - val result2 = checkout2.applyPromotionCode("code") - assertThat(result2.isFailure).isTrue() - assertThat(result2.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + CheckoutInstances.register("key1", checkout1, owner) + + // Attempt to unregister with a different instance - should be ignored. + CheckoutInstances.unregister("key1", checkout2) + + assertThat(CheckoutInstances["key1"]).isSameInstanceAs(checkout1) } @Test - fun `markIntegrationDismissed clears the flag for all instances`() = runTest { + fun `clear empties the map`() { val checkout1 = createCheckout(key = "key1") - val checkout2 = createCheckout(key = "key1") - CheckoutInstances.clear() - CheckoutInstances.add("key1", checkout1) - CheckoutInstances.add("key1", checkout2) + val checkout2 = createCheckout(key = "key2") - CheckoutInstances.markIntegrationLaunched("key1") - CheckoutInstances.markIntegrationDismissed("key1") + CheckoutInstances.register("key1", checkout1, owner) + CheckoutInstances.register("key2", checkout2, owner) - // After dismissing, mutations should not throw the integrationLaunched error. - // They will fail for other reasons (network), but they won't throw IllegalStateException. - networkRule.checkoutUpdate { response -> - response.setResponseCode(400) - response.setBody("""{"error": {"message": "error"}}""") - } - networkRule.checkoutUpdate { response -> - response.setResponseCode(400) - response.setBody("""{"error": {"message": "error"}}""") - } + CheckoutInstances.clear() - val result1 = checkout1.applyPromotionCode("code") - assertThat(result1.isFailure).isTrue() - val result2 = checkout2.applyPromotionCode("code") - assertThat(result2.isFailure).isTrue() + assertThat(CheckoutInstances["key1"]).isNull() + assertThat(CheckoutInstances["key2"]).isNull() } @Test fun `multiple keys coexist independently`() { val checkout1 = createCheckout(key = "key1") val checkout2 = createCheckout(key = "key2") - CheckoutInstances.clear() - CheckoutInstances.add("key1", checkout1) - CheckoutInstances.add("key2", checkout2) + CheckoutInstances.register("key1", checkout1, owner) + CheckoutInstances.register("key2", checkout2, owner) + + assertThat(CheckoutInstances["key1"]).isSameInstanceAs(checkout1) + assertThat(CheckoutInstances["key2"]).isSameInstanceAs(checkout2) + } + + @Test + fun `unregister after re-register with same instance leaves key absent`() { + val checkout = createCheckout(key = "key1") + + CheckoutInstances.register("key1", checkout, owner) + CheckoutInstances.register("key1", checkout, owner) // idempotent + CheckoutInstances.unregister("key1", checkout) - assertThat(CheckoutInstances["key1"]).containsExactly(checkout1) - assertThat(CheckoutInstances["key2"]).containsExactly(checkout2) + assertThat(CheckoutInstances["key1"]).isNull() } private fun createCheckout(key: String): Checkout { diff --git a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTestRule.kt b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTestRule.kt index 09ea40104ea..1c77ae68bf5 100644 --- a/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTestRule.kt +++ b/paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutInstancesTestRule.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.checkout import org.junit.rules.TestWatcher diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/EmbeddedPaymentElementTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/EmbeddedPaymentElementTest.kt index 2a1aad7b54b..9d8bf5a2bc4 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/EmbeddedPaymentElementTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/EmbeddedPaymentElementTest.kt @@ -12,6 +12,7 @@ import com.stripe.android.networktesting.testBodyFromFile import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder import com.stripe.android.paymentelement.embedded.content.EmbeddedConfigurationCoordinator import com.stripe.android.paymentelement.embedded.content.EmbeddedConfirmationHelper +import com.stripe.android.paymentelement.embedded.content.EmbeddedCheckoutRegistrar import com.stripe.android.paymentelement.embedded.content.FakeEmbeddedContentHelper import com.stripe.android.paymentelement.embedded.content.FakeEmbeddedStateHelper import com.stripe.android.paymentelement.embedded.content.PaymentOptionDisplayDataHolder @@ -81,6 +82,7 @@ internal class EmbeddedPaymentElementTest { ): EmbeddedPaymentElement.ConfigureResult = error("Not expected") }, stateHelper = FakeEmbeddedStateHelper(), + checkoutRegistrar = EmbeddedCheckoutRegistrar(), ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactoryTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactoryTest.kt index e46f5ec0cd4..ab68fcaafff 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactoryTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/gpay/GooglePayDisplayItemsFactoryTest.kt @@ -14,6 +14,7 @@ import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponseFactory import com.stripe.android.testing.PaymentConfigurationTestRule import org.junit.After +import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -45,7 +46,7 @@ class GooglePayDisplayItemsFactoryTest { } @Test - fun `returns empty list when no checkout instance exists for key`() { + fun `throws when no checkout instance exists for key`() { val metadata = PaymentMethodMetadataFactory.create( integrationMetadata = IntegrationMetadata.CheckoutSession( id = "cs_xxx", @@ -53,9 +54,11 @@ class GooglePayDisplayItemsFactoryTest { ), ) - val result = GooglePayDisplayItemsFactory.create(metadata) + val error = assertThrows(IllegalArgumentException::class.java) { + GooglePayDisplayItemsFactory.create(metadata) + } - assertThat(result).isEmpty() + assertThat(error).hasMessageThat().contains("nonexistent_key") } @Test @@ -209,7 +212,7 @@ class GooglePayDisplayItemsFactoryTest { ), ), ) - CheckoutInstances.add(INSTANCES_KEY, checkout) + CheckoutInstances.register(INSTANCES_KEY, checkout, this) val metadata = PaymentMethodMetadataFactory.create( integrationMetadata = IntegrationMetadata.CheckoutSession( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptorTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptorTest.kt index 0ad861f26cc..9b1b85bc6e8 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptorTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/confirmation/intent/CheckoutSessionConfirmationInterceptorTest.kt @@ -3,9 +3,9 @@ package com.stripe.android.paymentelement.confirmation.intent import android.app.Application import androidx.test.core.app.ApplicationProvider import app.cash.turbine.test -import app.cash.turbine.turbineScope import com.google.common.truth.Truth.assertThat import com.stripe.android.checkout.Checkout +import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.checkout.CheckoutInstancesTestRule import com.stripe.android.checkout.CheckoutStateFactory import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID @@ -351,31 +351,7 @@ class CheckoutSessionConfirmationInterceptorTest { } @Test - fun `successful confirm updates multiple Checkout instances`() = runScenario( - checkoutInstanceCount = 2 - ) { - val checkout1 = checkoutInstances[0] - val checkout2 = checkoutInstances[1] - turbineScope { - val checkoutSessionTurbine1 = checkout1.checkoutSession.testIn(backgroundScope) - val checkoutSessionTurbine2 = checkout2.checkoutSession.testIn(backgroundScope) - - assertThat(checkoutSessionTurbine1.awaitItem().id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) - assertThat(checkoutSessionTurbine2.awaitItem().id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) - - networkRule.checkoutConfirm { response -> - response.testBodyFromFile("checkout-session-confirm.json") - } - - interceptNewPm() - - assertThat(checkoutSessionTurbine1.awaitItem().id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) - assertThat(checkoutSessionTurbine2.awaitItem().id).isEqualTo(DEFAULT_CHECKOUT_SESSION_ID) - } - } - - @Test - fun `successful confirm with new PM updates registered Checkout instances`() = runScenario( + fun `successful confirm with new PM updates registered Checkout instance`() = runScenario( checkoutInstanceCount = 1 ) { val checkout = checkoutInstances.single() @@ -393,7 +369,7 @@ class CheckoutSessionConfirmationInterceptorTest { } @Test - fun `successful confirm with saved PM updates registered Checkout instances`() = runScenario( + fun `successful confirm with saved PM updates registered Checkout instance`() = runScenario( checkoutInstanceCount = 1 ) { val checkout = checkoutInstances.single() @@ -479,7 +455,7 @@ class CheckoutSessionConfirmationInterceptorTest { } @Test - fun `failed confirm does not update registered Checkout instances`() = runScenario( + fun `failed confirm does not update registered Checkout instance`() = runScenario( checkoutInstanceCount = 1, ) { networkRule.checkoutConfirm { response -> @@ -500,7 +476,7 @@ class CheckoutSessionConfirmationInterceptorTest { private fun runScenario( createPaymentMethodResult: Result = Result.success(PaymentMethodFixtures.CARD_PAYMENT_METHOD), customerMetadata: CustomerMetadata? = null, - checkoutInstanceCount: Int = 0, + checkoutInstanceCount: Int = 1, block: suspend Scenario.() -> Unit, ) { val stripeRepository = FakeCreatePaymentMethodRepository( @@ -542,6 +518,9 @@ class CheckoutSessionConfirmationInterceptorTest { state = CheckoutStateFactory.create(), ) } + checkoutInstances.firstOrNull()?.let { + CheckoutInstances.register(CheckoutStateFactory.DEFAULT_KEY, it, this) + } runTest { val scenario = Scenario( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncherTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncherTest.kt index d47f3c756e0..8d1e78be3ca 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncherTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/content/DefaultEmbeddedSheetLauncherTest.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.checkout.CheckoutInstancesTestRule import com.stripe.android.checkout.CheckoutStateFactory import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID @@ -474,6 +475,7 @@ internal class DefaultEmbeddedSheetLauncherTest { @Test fun `launchForm throws when checkout mutation is in flight`() = testScenario { val checkout = CheckoutStateFactory.createCheckout(applicationContext) + CheckoutInstances.register(checkout.internalState.key, checkout, this) networkRule.checkoutUpdate { response -> response.setBodyDelay(5, TimeUnit.SECONDS) response.testBodyFromFile("checkout-session-apply-discount.json") @@ -484,7 +486,7 @@ internal class DefaultEmbeddedSheetLauncherTest { val paymentMethodMetadata = PaymentMethodMetadataFactory.create( integrationMetadata = IntegrationMetadata.CheckoutSession( id = DEFAULT_CHECKOUT_SESSION_ID, - instancesKey = "test_key", + instancesKey = checkout.internalState.key, ), ) val state = EmbeddedConfirmationStateFixtures.defaultState() @@ -501,6 +503,7 @@ internal class DefaultEmbeddedSheetLauncherTest { @Test fun `launchManage throws when checkout mutation is in flight`() = testScenario { val checkout = CheckoutStateFactory.createCheckout(applicationContext) + CheckoutInstances.register(checkout.internalState.key, checkout, this) networkRule.checkoutUpdate { response -> response.setBodyDelay(5, TimeUnit.SECONDS) response.testBodyFromFile("checkout-session-apply-discount.json") @@ -511,7 +514,7 @@ internal class DefaultEmbeddedSheetLauncherTest { val paymentMethodMetadata = PaymentMethodMetadataFactory.create( integrationMetadata = IntegrationMetadata.CheckoutSession( id = DEFAULT_CHECKOUT_SESSION_ID, - instancesKey = "test_key", + instancesKey = checkout.internalState.key, ), ) val customerState = PaymentSheetFixtures.EMPTY_CUSTOMER_STATE diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityTest.kt index 498a1d09485..fbf92a0a97d 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/form/FormActivityTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentelement.embedded.form import android.app.Activity @@ -110,7 +112,8 @@ internal class FormActivityTest { @Test fun `onDestroy clears checkout integration launched flag`() { val checkout = CheckoutStateFactory.createCheckout(applicationContext) - CheckoutInstances.markIntegrationLaunched(CheckoutStateFactory.DEFAULT_KEY) + CheckoutInstances.register(CheckoutStateFactory.DEFAULT_KEY, checkout, this) + checkout.markIntegrationLaunched() launch( paymentMethodMetadata = PaymentMethodMetadataFactory.create( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivityTest.kt index 31d09255858..740f3528c3e 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentelement/embedded/sheet/EmbeddedSheetActivityTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentelement.embedded.sheet import android.app.Application @@ -202,7 +204,8 @@ internal class EmbeddedSheetActivityTest { @Test fun `onDestroy clears checkout integration launched flag`() { val checkout = CheckoutStateFactory.createCheckout(applicationContext) - CheckoutInstances.markIntegrationLaunched(CheckoutStateFactory.DEFAULT_KEY) + CheckoutInstances.register(CheckoutStateFactory.DEFAULT_KEY, checkout, this) + checkout.markIntegrationLaunched() launch( paymentMethodMetadata = PaymentMethodMetadataFactory.create( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt index 455345b8580..dbbf8af73cf 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentsheet import android.content.Context @@ -473,7 +475,8 @@ internal class PaymentOptionsActivityTest { @Test fun `onDestroy clears checkout integration launched flag`() { val checkout = CheckoutStateFactory.createCheckout(context) - CheckoutInstances.markIntegrationLaunched(CheckoutStateFactory.DEFAULT_KEY) + CheckoutInstances.register(CheckoutStateFactory.DEFAULT_KEY, checkout, this) + checkout.markIntegrationLaunched() val args = PAYMENT_OPTIONS_CONTRACT_ARGS.copy( state = PAYMENT_OPTIONS_CONTRACT_ARGS.state.copy( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt index b2cf8bf08c9..2544668bd83 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentsheet import android.content.Context @@ -214,7 +216,8 @@ internal class PaymentSheetActivityTest { @Test fun `onDestroy clears checkout integration launched flag`() { val checkout = CheckoutStateFactory.createCheckout(context) - CheckoutInstances.markIntegrationLaunched(CheckoutStateFactory.DEFAULT_KEY) + CheckoutInstances.register(CheckoutStateFactory.DEFAULT_KEY, checkout, this) + checkout.markIntegrationLaunched() val viewModel = createViewModel( integrationMetadata = IntegrationMetadata.CheckoutSession( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt index dee81bc0019..ec7460ee9b7 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/flowcontroller/DefaultFlowControllerTest.kt @@ -1,3 +1,5 @@ +@file:OptIn(com.stripe.android.paymentelement.CheckoutSessionPreview::class) + package com.stripe.android.paymentsheet.flowcontroller import android.app.Application @@ -15,6 +17,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.DefaultCardFundingFilter import com.stripe.android.PaymentConfiguration +import com.stripe.android.checkout.CheckoutInstances import com.stripe.android.checkout.CheckoutInstancesTestRule import com.stripe.android.checkout.CheckoutStateFactory import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID @@ -2393,6 +2396,7 @@ internal class DefaultFlowControllerTest { ), ) flowController.configureExpectingSuccess() + CheckoutInstances.register("test_key", checkout, this) networkRule.checkoutUpdate { response -> response.setBodyDelay(5, TimeUnit.SECONDS) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdaterTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdaterTest.kt deleted file mode 100644 index 2409f8bb55b..00000000000 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/CheckoutCurrencyUpdaterTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.stripe.android.paymentsheet.verticalmode - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat -import com.stripe.android.checkout.Checkout -import com.stripe.android.checkout.CheckoutInstancesTestRule -import com.stripe.android.checkout.CheckoutStateFactory -import com.stripe.android.checkouttesting.DEFAULT_CHECKOUT_SESSION_ID -import com.stripe.android.checkouttesting.checkoutUpdate -import com.stripe.android.core.networking.DefaultStripeNetworkClient -import com.stripe.android.networktesting.NetworkRule -import com.stripe.android.networktesting.testBodyFromFile -import com.stripe.android.paymentelement.CheckoutSessionPreview -import com.stripe.android.paymentsheet.PaymentSheet -import com.stripe.android.paymentsheet.repositories.CheckoutSessionRepository -import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponseFactory -import com.stripe.android.paymentsheet.repositories.ElementsSessionClientParams -import com.stripe.android.paymentsheet.state.PaymentElementLoader -import com.stripe.android.testing.PaymentConfigurationTestRule -import com.stripe.android.utils.FakePaymentElementLoader -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@OptIn(CheckoutSessionPreview::class) -@RunWith(RobolectricTestRunner::class) -class CheckoutCurrencyUpdaterTest { - - private val applicationContext = ApplicationProvider.getApplicationContext() - private val networkRule = NetworkRule() - - @get:Rule - val ruleChain: RuleChain = RuleChain - .outerRule(networkRule) - .around(PaymentConfigurationTestRule(applicationContext)) - .around(CheckoutInstancesTestRule()) - - private val checkoutSessionRepository = CheckoutSessionRepository( - clientParams = ElementsSessionClientParams( - mobileAppId = "com.stripe.android.test", - mobileSessionIdProvider = { "test_session" }, - ), - stripeNetworkClient = DefaultStripeNetworkClient(), - publishableKeyProvider = { "pk_test_123" }, - stripeAccountIdProvider = { null }, - ) - - @Test - fun `updateCurrency success - calls repo, updates checkout instances, calls loader`() = runTest { - val instancesKey = CheckoutStateFactory.DEFAULT_KEY - val initialResponse = CheckoutSessionResponseFactory.create(id = DEFAULT_CHECKOUT_SESSION_ID) - val checkout = Checkout.createWithState( - applicationContext, - CheckoutStateFactory.create(key = instancesKey, checkoutSessionResponse = initialResponse), - ) - - networkRule.checkoutUpdate { response -> - response.testBodyFromFile("checkout-session-init.json") - } - - var capturedInitMode: PaymentElementLoader.InitializationMode? = null - val fakeLoader = FakePaymentElementLoader() - val trackingLoader = object : PaymentElementLoader { - override suspend fun load( - initializationMode: PaymentElementLoader.InitializationMode, - integrationConfiguration: PaymentElementLoader.Configuration, - metadata: PaymentElementLoader.Metadata, - ): Result { - capturedInitMode = initializationMode - return fakeLoader.load(initializationMode, integrationConfiguration, metadata) - } - } - - val config = PaymentSheet.Configuration("Test Merchant") - val updater = DefaultCheckoutCurrencyUpdater(checkoutSessionRepository, trackingLoader) - - val result = updater.updateCurrency( - instancesKey = instancesKey, - sessionId = DEFAULT_CHECKOUT_SESSION_ID, - currencyCode = "eur", - config = config, - initializedViaCompose = false, - ) - - assertThat(result.isSuccess).isTrue() - val updateResult = result.getOrThrow() - - // Verify CheckoutInstances was updated with the response from the network - assertThat(checkout.internalState.checkoutSessionResponse).isEqualTo(updateResult.checkoutSessionResponse) - - // Verify loader was called with the updated init mode - val capturedMode = capturedInitMode as? PaymentElementLoader.InitializationMode.CheckoutSession - assertThat(capturedMode?.instancesKey).isEqualTo(instancesKey) - assertThat(capturedMode?.checkoutSessionResponse).isEqualTo(updateResult.checkoutSessionResponse) - } - - @Test - fun `updateCurrency failure on API call - returns failure and loader not called`() = runTest { - networkRule.checkoutUpdate { response -> - response.setResponseCode(400) - response.setBody("""{"error": {"message": "Invalid currency"}}""") - } - - val loaderNotExpectedToBeCalledLoader = object : PaymentElementLoader { - override suspend fun load( - initializationMode: PaymentElementLoader.InitializationMode, - integrationConfiguration: PaymentElementLoader.Configuration, - metadata: PaymentElementLoader.Metadata, - ): Result { - throw AssertionError("Loader should not be called when API fails") - } - } - - val updater = DefaultCheckoutCurrencyUpdater(checkoutSessionRepository, loaderNotExpectedToBeCalledLoader) - - val result = updater.updateCurrency( - instancesKey = CheckoutStateFactory.DEFAULT_KEY, - sessionId = DEFAULT_CHECKOUT_SESSION_ID, - currencyCode = "invalid", - config = PaymentSheet.Configuration("Test Merchant"), - initializedViaCompose = false, - ) - - assertThat(result.isFailure).isTrue() - } - - @Test - fun `updateCurrency failure on loader - returns failure`() = runTest { - networkRule.checkoutUpdate { response -> - response.testBodyFromFile("checkout-session-init.json") - } - - val failingLoader = FakePaymentElementLoader(shouldFail = true) - val updater = DefaultCheckoutCurrencyUpdater(checkoutSessionRepository, failingLoader) - - val result = updater.updateCurrency( - instancesKey = CheckoutStateFactory.DEFAULT_KEY, - sessionId = DEFAULT_CHECKOUT_SESSION_ID, - currencyCode = "eur", - config = PaymentSheet.Configuration("Test Merchant"), - initializedViaCompose = false, - ) - - assertThat(result.isFailure).isTrue() - } -} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/FakeCheckoutCurrencyUpdater.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/FakeCheckoutCurrencyUpdater.kt deleted file mode 100644 index 9bde205f3da..00000000000 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/verticalmode/FakeCheckoutCurrencyUpdater.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.stripe.android.paymentsheet.verticalmode - -import app.cash.turbine.Turbine -import com.stripe.android.paymentsheet.PaymentSheet - -internal class FakeCheckoutCurrencyUpdater( - private val result: Result = - Result.failure(IllegalStateException("No result configured")), -) : CheckoutCurrencyUpdater { - - val calls = Turbine() - - override suspend fun updateCurrency( - instancesKey: String, - sessionId: String, - currencyCode: String, - config: PaymentSheet.Configuration, - initializedViaCompose: Boolean, - ): Result { - calls.add( - UpdateCurrencyCall( - instancesKey = instancesKey, - sessionId = sessionId, - currencyCode = currencyCode, - config = config, - initializedViaCompose = initializedViaCompose, - ) - ) - return result - } - - fun ensureAllEventsConsumed() { - calls.ensureAllEventsConsumed() - } - - data class UpdateCurrencyCall( - val instancesKey: String, - val sessionId: String, - val currencyCode: String, - val config: PaymentSheet.Configuration, - val initializedViaCompose: Boolean, - ) -}