diff --git a/payments-core/src/test/java/com/stripe/android/googlepaylauncher/GooglePayPaymentMethodLauncherViewModelTest.kt b/payments-core/src/test/java/com/stripe/android/googlepaylauncher/GooglePayPaymentMethodLauncherViewModelTest.kt index e4adf36f873..489f8264ad5 100644 --- a/payments-core/src/test/java/com/stripe/android/googlepaylauncher/GooglePayPaymentMethodLauncherViewModelTest.kt +++ b/payments-core/src/test/java/com/stripe/android/googlepaylauncher/GooglePayPaymentMethodLauncherViewModelTest.kt @@ -185,7 +185,7 @@ class GooglePayPaymentMethodLauncherViewModelTest { verify(factorySpy, times(0)).fallbackInitialize(any()) assertThat(createdViewModel).isEqualTo(viewModel) - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } } diff --git a/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionActivityTest.kt b/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionActivityTest.kt index 9539f480e28..ce7a9942089 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionActivityTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionActivityTest.kt @@ -31,7 +31,7 @@ class Stripe3ds2TransactionActivityTest { @After fun cleanUpInjector() { - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } @Test diff --git a/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionViewModelFactoryTest.kt b/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionViewModelFactoryTest.kt index e509b72e16c..1f617874c1e 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionViewModelFactoryTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/core/authentication/threeds2/Stripe3ds2TransactionViewModelFactoryTest.kt @@ -88,7 +88,7 @@ class Stripe3ds2TransactionViewModelFactoryTest { verify(factorySpy, times(0)).fallbackInitialize(any()) assertThat(createdViewModel).isEqualTo(viewModel) - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } } diff --git a/payments-core/src/test/java/com/stripe/android/payments/core/injection/WeakMapInjectorRegistryTest.kt b/payments-core/src/test/java/com/stripe/android/payments/core/injection/WeakMapInjectorRegistryTest.kt index 0664f21e923..7bc26c2f941 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/core/injection/WeakMapInjectorRegistryTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/core/injection/WeakMapInjectorRegistryTest.kt @@ -19,7 +19,7 @@ class WeakMapInjectorRegistryTest { @Before fun clearStaticCache() { - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } @Test diff --git a/payments-core/src/test/java/com/stripe/android/payments/paymentlauncher/PaymentLauncherViewModelTest.kt b/payments-core/src/test/java/com/stripe/android/payments/paymentlauncher/PaymentLauncherViewModelTest.kt index 00d438b0db9..bf2e1972989 100644 --- a/payments-core/src/test/java/com/stripe/android/payments/paymentlauncher/PaymentLauncherViewModelTest.kt +++ b/payments-core/src/test/java/com/stripe/android/payments/paymentlauncher/PaymentLauncherViewModelTest.kt @@ -487,7 +487,7 @@ class PaymentLauncherViewModelTest { verify(factorySpy, times(0)).fallbackInitialize(any()) assertThat(createdViewModel).isEqualTo(vmToBeReturned) - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } @Test diff --git a/paymentsheet/api/paymentsheet.api b/paymentsheet/api/paymentsheet.api index a9ffa52b9d4..0e6a3d6df01 100644 --- a/paymentsheet/api/paymentsheet.api +++ b/paymentsheet/api/paymentsheet.api @@ -10,11 +10,11 @@ public abstract interface class com/stripe/android/paymentsheet/PaymentOptionCal } public final class com/stripe/android/paymentsheet/PaymentOptionsViewModel_Factory : dagger/internal/Factory { - public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V - public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/PaymentOptionsViewModel_Factory; + public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/PaymentOptionsViewModel_Factory; public fun get ()Lcom/stripe/android/paymentsheet/PaymentOptionsViewModel; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Lcom/stripe/android/paymentsheet/PaymentOptionContract$Args;Lkotlin/jvm/functions/Function1;Lcom/stripe/android/paymentsheet/analytics/EventReporter;Lcom/stripe/android/paymentsheet/repositories/CustomerRepository;Lkotlin/coroutines/CoroutineContext;Landroid/app/Application;Lcom/stripe/android/core/Logger;Ljava/lang/String;Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;)Lcom/stripe/android/paymentsheet/PaymentOptionsViewModel; + public static fun newInstance (Lcom/stripe/android/paymentsheet/PaymentOptionContract$Args;Lkotlin/jvm/functions/Function1;Lcom/stripe/android/paymentsheet/analytics/EventReporter;Lcom/stripe/android/paymentsheet/repositories/CustomerRepository;Lkotlin/coroutines/CoroutineContext;Landroid/app/Application;Lcom/stripe/android/core/Logger;Ljava/lang/String;Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Landroidx/lifecycle/SavedStateHandle;)Lcom/stripe/android/paymentsheet/PaymentOptionsViewModel; } public final class com/stripe/android/paymentsheet/PaymentOptionsViewModel_Factory_MembersInjector : dagger/MembersInjector { @@ -309,11 +309,11 @@ public abstract interface class com/stripe/android/paymentsheet/PaymentSheetResu } public final class com/stripe/android/paymentsheet/PaymentSheetViewModel_Factory : dagger/internal/Factory { - public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V - public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/PaymentSheetViewModel_Factory; + public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/PaymentSheetViewModel_Factory; public fun get ()Lcom/stripe/android/paymentsheet/PaymentSheetViewModel; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Landroid/app/Application;Lcom/stripe/android/paymentsheet/PaymentSheetContract$Args;Lcom/stripe/android/paymentsheet/analytics/EventReporter;Ldagger/Lazy;Lcom/stripe/android/paymentsheet/repositories/StripeIntentRepository;Lcom/stripe/android/paymentsheet/model/StripeIntentValidator;Lcom/stripe/android/paymentsheet/repositories/CustomerRepository;Lcom/stripe/android/paymentsheet/PrefsRepository;Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Lcom/stripe/android/payments/paymentlauncher/StripePaymentLauncherAssistedFactory;Lcom/stripe/android/googlepaylauncher/injection/GooglePayPaymentMethodLauncherFactory;Lcom/stripe/android/core/Logger;Lkotlin/coroutines/CoroutineContext;Ljava/lang/String;)Lcom/stripe/android/paymentsheet/PaymentSheetViewModel; + public static fun newInstance (Landroid/app/Application;Lcom/stripe/android/paymentsheet/PaymentSheetContract$Args;Lcom/stripe/android/paymentsheet/analytics/EventReporter;Ldagger/Lazy;Lcom/stripe/android/paymentsheet/repositories/StripeIntentRepository;Lcom/stripe/android/paymentsheet/model/StripeIntentValidator;Lcom/stripe/android/paymentsheet/repositories/CustomerRepository;Lcom/stripe/android/paymentsheet/PrefsRepository;Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Lcom/stripe/android/payments/paymentlauncher/StripePaymentLauncherAssistedFactory;Lcom/stripe/android/googlepaylauncher/injection/GooglePayPaymentMethodLauncherFactory;Lcom/stripe/android/core/Logger;Lkotlin/coroutines/CoroutineContext;Ljava/lang/String;Landroidx/lifecycle/SavedStateHandle;)Lcom/stripe/android/paymentsheet/PaymentSheetViewModel; } public final class com/stripe/android/paymentsheet/PaymentSheetViewModel_Factory_MembersInjector : dagger/MembersInjector { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt index 97b7a3f6cbb..5cf5390858b 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt @@ -15,43 +15,28 @@ import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager -import com.stripe.android.model.StripeIntent import com.stripe.android.core.injection.InjectorKey -import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.model.StripeIntent import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddPaymentMethodBinding import com.stripe.android.paymentsheet.forms.FormFieldValues -import com.stripe.android.ui.core.Amount import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SupportedPaymentMethod import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.ComposeFormDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments import com.stripe.android.paymentsheet.paymentdatacollection.TransformToPaymentMethodCreateParams -import com.stripe.android.paymentsheet.ui.AddPaymentMethodsFragmentFactory import com.stripe.android.paymentsheet.ui.AnimationConstants import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel +import com.stripe.android.ui.core.Amount import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -internal abstract class BaseAddPaymentMethodFragment( - private val eventReporter: EventReporter -) : Fragment() { +internal abstract class BaseAddPaymentMethodFragment : Fragment() { abstract val viewModelFactory: ViewModelProvider.Factory abstract val sheetViewModel: BaseSheetViewModel<*> protected lateinit var addPaymentMethodHeader: TextView - private lateinit var selectedPaymentMethod: SupportedPaymentMethod - - override fun onCreate(savedInstanceState: Bundle?) { - // When the fragment is destroyed and recreated, the child fragment is re-instantiated - // during onCreate, so the factory must be set before calling super. - childFragmentManager.fragmentFactory = AddPaymentMethodsFragmentFactory( - sheetViewModel::class.java, viewModelFactory - ) - super.onCreate(savedInstanceState) - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -85,7 +70,7 @@ internal abstract class BaseAddPaymentMethodFragment( ) val selectedPaymentMethodIndex = paymentMethods.indexOf( - SupportedPaymentMethod.fromCode(savedInstanceState?.getString(SELECTED_PAYMENT_METHOD)) + sheetViewModel.getAddFragmentSelectedLPM() ).takeUnless { it == -1 } ?: 0 if (paymentMethods.size > 1) { @@ -93,32 +78,46 @@ internal abstract class BaseAddPaymentMethodFragment( } if (paymentMethods.isNotEmpty()) { - replacePaymentMethodFragment(paymentMethods[selectedPaymentMethodIndex]) + // If the activity is destroyed and recreated, then the fragment is already present + // and doesn't need to be replaced, only the selected payment method needs to be set + if (savedInstanceState == null) { + replacePaymentMethodFragment(paymentMethods[selectedPaymentMethodIndex]) + } } sheetViewModel.processing.observe(viewLifecycleOwner) { isProcessing -> (getFragment() as? ComposeFormDataCollectionFragment)?.setProcessing(isProcessing) } + // If the activity was destroyed and recreated then we need to re-attach the fragment, + // as attach will not be called again. + childFragmentManager.fragments.forEach { fragment -> + attachComposeFragmentViewModel(fragment) + } + childFragmentManager.addFragmentOnAttachListener { _, fragment -> - (fragment as? ComposeFormDataCollectionFragment)?.let { formFragment -> - // Need to access the formViewModel so it is constructed. - val formViewModel = formFragment.formViewModel - viewLifecycleOwner.lifecycleScope.launch { - formViewModel.completeFormValues.collect { formFieldValues -> - sheetViewModel.updateSelection( - transformToPaymentSelection( - formFieldValues, - formFragment.paramKeySpec, - selectedPaymentMethod - ) + attachComposeFragmentViewModel(fragment) + } + + sheetViewModel.eventReporter.onShowNewPaymentOptionForm() + } + + private fun attachComposeFragmentViewModel(fragment: Fragment) { + (fragment as? ComposeFormDataCollectionFragment)?.let { formFragment -> + // Need to access the formViewModel so it is constructed. + val formViewModel = formFragment.formViewModel + viewLifecycleOwner.lifecycleScope.launch { + formViewModel.completeFormValues.collect { formFieldValues -> + sheetViewModel.updateSelection( + transformToPaymentSelection( + formFieldValues, + formFragment.paramKeySpec, + sheetViewModel.getAddFragmentSelectedLPM() ) - } + ) } } } - - eventReporter.onShowNewPaymentOptionForm() } private fun setupRecyclerView( @@ -168,13 +167,8 @@ internal abstract class BaseAddPaymentMethodFragment( replacePaymentMethodFragment(paymentMethod) } - override fun onSaveInstanceState(outState: Bundle) { - outState.putString(SELECTED_PAYMENT_METHOD, selectedPaymentMethod.type.code) - super.onSaveInstanceState(outState) - } - private fun replacePaymentMethodFragment(paymentMethod: SupportedPaymentMethod) { - selectedPaymentMethod = paymentMethod + sheetViewModel.setAddFragmentSelectedLPM(paymentMethod) val args = requireArguments() args.putParcelable( @@ -208,12 +202,15 @@ internal abstract class BaseAddPaymentMethodFragment( childFragmentManager.findFragmentById(R.id.payment_method_fragment_container) companion object { - private const val SELECTED_PAYMENT_METHOD = "selected_pm" private fun fragmentForPaymentMethod(paymentMethod: SupportedPaymentMethod) = when (paymentMethod) { - SupportedPaymentMethod.Card -> CardDataCollectionFragment::class.java - else -> ComposeFormDataCollectionFragment::class.java + SupportedPaymentMethod.Card -> { + CardDataCollectionFragment::class.java + } + else -> { + ComposeFormDataCollectionFragment::class.java + } } private val transformToPaymentMethodCreateParams = TransformToPaymentMethodCreateParams() diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BasePaymentMethodsListFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BasePaymentMethodsListFragment.kt index 8972a2f14f4..cd222cd207c 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BasePaymentMethodsListFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BasePaymentMethodsListFragment.kt @@ -9,7 +9,6 @@ import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager -import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetPaymentMethodsListBinding import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.PaymentSelection @@ -18,8 +17,7 @@ import com.stripe.android.paymentsheet.ui.BaseSheetActivity import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel internal abstract class BasePaymentMethodsListFragment( - private val canClickSelectedItem: Boolean, - private val eventReporter: EventReporter + private val canClickSelectedItem: Boolean ) : Fragment( R.layout.fragment_paymentsheet_payment_methods_list ) { @@ -53,7 +51,7 @@ internal abstract class BasePaymentMethodsListFragment( this.config = nullableConfig setHasOptionsMenu(!sheetViewModel.paymentMethods.value.isNullOrEmpty()) - eventReporter.onShowExistingPaymentOptions() + sheetViewModel.eventReporter.onShowExistingPaymentOptions() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 b3580ca93a1..807a29cc4d6 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt @@ -35,7 +35,9 @@ internal class PaymentOptionsActivity : BaseSheetActivity() internal var viewModelFactory: ViewModelProvider.Factory = PaymentOptionsViewModel.Factory( { application }, - { requireNotNull(starterArgs) } + { requireNotNull(starterArgs) }, + this, + intent?.extras ) override val viewModel: PaymentOptionsViewModel by viewModels { viewModelFactory } @@ -78,13 +80,13 @@ internal class PaymentOptionsActivity : BaseSheetActivity() setupContinueButton(viewBinding.continueButton) viewModel.transition.observe(this) { event -> - val transitionTarget = event.getContentIfNotHandled() - if (transitionTarget != null) { + event?.getContentIfNotHandled()?.let { transitionTarget -> onTransitionTarget( transitionTarget, bundleOf( - EXTRA_STARTER_ARGS to starterArgs, - EXTRA_FRAGMENT_CONFIG to transitionTarget.fragmentConfig + PaymentSheetActivity.EXTRA_STARTER_ARGS to starterArgs, + PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to + transitionTarget.fragmentConfig ) ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragment.kt index ba5a5b636bb..5fc6d711b59 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragment.kt @@ -1,19 +1,18 @@ package com.stripe.android.paymentsheet +import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModelProvider -import com.stripe.android.paymentsheet.analytics.EventReporter -internal class PaymentOptionsAddPaymentMethodFragment( - eventReporter: EventReporter -) : BaseAddPaymentMethodFragment(eventReporter) { +internal class PaymentOptionsAddPaymentMethodFragment : BaseAddPaymentMethodFragment() { override val viewModelFactory: ViewModelProvider.Factory = PaymentOptionsViewModel.Factory( { requireActivity().application }, { requireNotNull( requireArguments().getParcelable(PaymentOptionsActivity.EXTRA_STARTER_ARGS) ) - } + }, + (activity as? AppCompatActivity) ?: this ) override val sheetViewModel by activityViewModels { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsListFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsListFragment.kt index a2077e5a4d9..3fa8b10e4b5 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsListFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsListFragment.kt @@ -2,17 +2,14 @@ package com.stripe.android.paymentsheet import android.os.Bundle import android.view.View +import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels -import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetPaymentMethodsListBinding import com.stripe.android.paymentsheet.model.PaymentSelection -internal class PaymentOptionsListFragment( - eventReporter: EventReporter -) : BasePaymentMethodsListFragment( - canClickSelectedItem = true, - eventReporter +internal class PaymentOptionsListFragment() : BasePaymentMethodsListFragment( + canClickSelectedItem = true ) { private val activityViewModel by activityViewModels { PaymentOptionsViewModel.Factory( @@ -21,7 +18,8 @@ internal class PaymentOptionsListFragment( requireNotNull( requireArguments().getParcelable(PaymentOptionsActivity.EXTRA_STARTER_ARGS) ) - } + }, + (activity as? AppCompatActivity) ?: this ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt index 695f57c8135..10da28d850e 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt @@ -1,24 +1,27 @@ package com.stripe.android.paymentsheet import android.app.Application +import android.os.Bundle import androidx.annotation.VisibleForTesting +import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import androidx.savedstate.SavedStateRegistryOwner import com.stripe.android.core.Logger import com.stripe.android.core.injection.IOContext import com.stripe.android.core.injection.Injectable import com.stripe.android.core.injection.InjectorKey import com.stripe.android.core.injection.injectWithFallback import com.stripe.android.paymentsheet.analytics.EventReporter -import com.stripe.android.ui.core.forms.resources.ResourceRepository import com.stripe.android.paymentsheet.injection.DaggerPaymentOptionsViewModelFactoryComponent import com.stripe.android.paymentsheet.injection.PaymentOptionsViewModelSubcomponent import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.repositories.CustomerRepository import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel +import com.stripe.android.ui.core.forms.resources.ResourceRepository import javax.inject.Inject import javax.inject.Provider import kotlin.coroutines.CoroutineContext @@ -34,7 +37,8 @@ internal class PaymentOptionsViewModel @Inject constructor( application: Application, logger: Logger, @InjectorKey injectorKey: String, - resourceRepository: ResourceRepository + resourceRepository: ResourceRepository, + savedStateHandle: SavedStateHandle ) : BaseSheetViewModel( config = args.config, prefsRepository = prefsRepositoryFactory(args.config?.customer), @@ -44,7 +48,8 @@ internal class PaymentOptionsViewModel @Inject constructor( application = application, logger = logger, injectorKey = injectorKey, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = savedStateHandle ) { @VisibleForTesting internal val _paymentOptionResult = MutableLiveData() @@ -66,10 +71,10 @@ internal class PaymentOptionsViewModel @Inject constructor( } ?: false init { - _isGooglePayReady.value = args.isGooglePayReady + savedStateHandle.set(SAVE_GOOGLE_PAY_READY, args.isGooglePayReady) setStripeIntent(args.stripeIntent) - _paymentMethods.value = args.paymentMethods - _processing.postValue(false) + savedStateHandle.set(SAVE_PAYMENT_METHODS, args.paymentMethods) + savedStateHandle.set(SAVE_PROCESSING, false) } override fun onFatal(throwable: Throwable) { @@ -138,8 +143,11 @@ internal class PaymentOptionsViewModel @Inject constructor( internal class Factory( private val applicationSupplier: () -> Application, - private val starterArgsSupplier: () -> PaymentOptionContract.Args - ) : ViewModelProvider.Factory, Injectable { + private val starterArgsSupplier: () -> PaymentOptionContract.Args, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null + ) : AbstractSavedStateViewModelFactory(owner, defaultArgs), + Injectable { internal data class FallbackInitializeParam( val application: Application, val productUsage: Set @@ -157,14 +165,21 @@ internal class PaymentOptionsViewModel @Inject constructor( Provider @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { + override fun create( + key: String, + modelClass: Class, + savedStateHandle: SavedStateHandle + ): T { val application = applicationSupplier() val starterArgs = starterArgsSupplier() injectWithFallback( starterArgsSupplier().injectorKey, FallbackInitializeParam(application, starterArgs.productUsage) ) - return subComponentBuilderProvider.get().application(application).args(starterArgs) + return subComponentBuilderProvider.get() + .application(application) + .args(starterArgs) + .savedStateHandle(savedStateHandle) .build().viewModel as T } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt index 34eb6379fc2..f63efdacf52 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt @@ -20,12 +20,12 @@ import com.google.android.material.appbar.MaterialToolbar import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContract import com.stripe.android.paymentsheet.PaymentSheetViewModel.CheckoutIdentifier import com.stripe.android.paymentsheet.databinding.ActivityPaymentSheetBinding -import com.stripe.android.ui.core.Amount import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.PaymentSheetViewState import com.stripe.android.paymentsheet.ui.AnimationConstants import com.stripe.android.paymentsheet.ui.BaseSheetActivity import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel +import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.CurrencyFormatter import kotlinx.coroutines.launch import java.security.InvalidParameterException @@ -40,7 +40,9 @@ internal class PaymentSheetActivity : BaseSheetActivity() { internal var viewModelFactory: ViewModelProvider.Factory = PaymentSheetViewModel.Factory( { application }, - { requireNotNull(starterArgs) } + { requireNotNull(starterArgs) }, + this, + intent?.extras ) override val viewModel: PaymentSheetViewModel by viewModels { viewModelFactory } @@ -106,7 +108,12 @@ internal class PaymentSheetActivity : BaseSheetActivity() { viewModel::onGooglePayResult ) ) - viewModel.maybeFetchStripeIntent() + + if (!viewModel.maybeFetchStripeIntent()) { + // The buy button needs to be made visible since it is gone in the xml + buttonContainer.isVisible = true + viewBinding.buyButton.isVisible = true + } starterArgs.statusBarColor?.let { window.statusBarColor = it @@ -120,29 +127,36 @@ internal class PaymentSheetActivity : BaseSheetActivity() { setupBuyButton() - viewModel.transition.observe(this) { event -> - updateErrorMessage() - val transitionTarget = event.getContentIfNotHandled() - if (transitionTarget != null) { - onTransitionTarget( - transitionTarget, - bundleOf( - EXTRA_STARTER_ARGS to starterArgs, - EXTRA_FRAGMENT_CONFIG to transitionTarget.fragmentConfig + viewModel.transition.observe(this) { transitionEvent -> + transitionEvent?.let { + updateErrorMessage() + it.getContentIfNotHandled()?.let { transitionTarget -> + onTransitionTarget( + transitionTarget, + bundleOf( + EXTRA_STARTER_ARGS to starterArgs, + EXTRA_FRAGMENT_CONFIG to transitionTarget.fragmentConfig + ) ) - ) + } } } viewModel.fragmentConfigEvent.observe(this) { event -> val config = event.getContentIfNotHandled() if (config != null) { - val target = if (viewModel.paymentMethods.value.isNullOrEmpty()) { - PaymentSheetViewModel.TransitionTarget.AddPaymentMethodSheet(config) - } else { - PaymentSheetViewModel.TransitionTarget.SelectSavedPaymentMethod(config) + + // We only want to do this if the loading fragment is shown. Otherwise this causes + // a new fragment to be created if the activity was destroyed and recreated. + if (supportFragmentManager.fragments.firstOrNull() is PaymentSheetLoadingFragment) { + val target = if (viewModel.paymentMethods.value.isNullOrEmpty()) { + viewModel.updateSelection(null) + PaymentSheetViewModel.TransitionTarget.AddPaymentMethodSheet(config) + } else { + PaymentSheetViewModel.TransitionTarget.SelectSavedPaymentMethod(config) + } + viewModel.transitionTo(target) } - viewModel.transitionTo(target) } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragment.kt index 08d1342d3e4..b80085370a0 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragment.kt @@ -2,27 +2,27 @@ package com.stripe.android.paymentsheet import android.os.Bundle import android.view.View +import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModelProvider import com.stripe.android.paymentsheet.PaymentSheetViewModel.CheckoutIdentifier -import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddPaymentMethodBinding import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.PaymentSheetViewState import com.stripe.android.paymentsheet.ui.BaseSheetActivity import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel -internal class PaymentSheetAddPaymentMethodFragment( - eventReporter: EventReporter -) : BaseAddPaymentMethodFragment(eventReporter) { +internal class PaymentSheetAddPaymentMethodFragment() : BaseAddPaymentMethodFragment() { override val viewModelFactory: ViewModelProvider.Factory = PaymentSheetViewModel.Factory( { requireActivity().application }, { requireNotNull( requireArguments().getParcelable(PaymentSheetActivity.EXTRA_STARTER_ARGS) ) - } + }, + (activity as? AppCompatActivity) ?: this, + (activity as? AppCompatActivity)?.intent?.extras ) override val sheetViewModel by activityViewModels { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetListFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetListFragment.kt index 479b0013912..fd6081122cf 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetListFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetListFragment.kt @@ -2,18 +2,15 @@ package com.stripe.android.paymentsheet import android.os.Bundle import android.view.View +import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels -import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetPaymentMethodsListBinding import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.CurrencyFormatter -internal class PaymentSheetListFragment( - eventReporter: EventReporter -) : BasePaymentMethodsListFragment( - canClickSelectedItem = false, - eventReporter +internal class PaymentSheetListFragment() : BasePaymentMethodsListFragment( + canClickSelectedItem = false ) { private val currencyFormatter = CurrencyFormatter() private val activityViewModel by activityViewModels { @@ -23,7 +20,8 @@ internal class PaymentSheetListFragment( requireNotNull( requireArguments().getParcelable(PaymentSheetActivity.EXTRA_STARTER_ARGS) ) - } + }, + (activity as? AppCompatActivity) ?: this ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt index f356448f870..42b7f725193 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt @@ -1,17 +1,20 @@ package com.stripe.android.paymentsheet import android.app.Application +import android.os.Bundle import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IntegerRes import androidx.annotation.VisibleForTesting +import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.viewModelScope +import androidx.savedstate.SavedStateRegistryOwner import com.stripe.android.PaymentConfiguration import com.stripe.android.core.Logger import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY @@ -86,7 +89,8 @@ internal class PaymentSheetViewModel @Inject internal constructor( private val googlePayPaymentMethodLauncherFactory: GooglePayPaymentMethodLauncherFactory, logger: Logger, @IOContext workContext: CoroutineContext, - @InjectorKey injectorKey: String + @InjectorKey injectorKey: String, + savedStateHandle: SavedStateHandle ) : BaseSheetViewModel( application = application, config = args.config, @@ -96,7 +100,8 @@ internal class PaymentSheetViewModel @Inject internal constructor( workContext = workContext, logger = logger, injectorKey = injectorKey, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = savedStateHandle ) { private val confirmParamsFactory = ConfirmStripeIntentParamsFactory.createFactory( args.clientSecret @@ -170,7 +175,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( init { eventReporter.onInit(config) if (googlePayLauncherConfig == null) { - _isGooglePayReady.value = false + savedStateHandle.set(SAVE_GOOGLE_PAY_READY, false) } } @@ -184,7 +189,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( lifecycleScope = lifecycleScope, config = config, readyCallback = { isReady -> - _isGooglePayReady.value = isReady + savedStateHandle.set(SAVE_GOOGLE_PAY_READY, isReady) }, activityResultLauncher = activityResultLauncher ) @@ -196,20 +201,21 @@ internal class PaymentSheetViewModel @Inject internal constructor( * not fetched yet. If successful, continues through validation and fetching the saved payment * methods for the customer. */ - internal fun maybeFetchStripeIntent() { - if (stripeIntent.value == null) { - viewModelScope.launch { - runCatching { - stripeIntentRepository.get(args.clientSecret) - }.fold( - onSuccess = ::onStripeIntentFetchResponse, - onFailure = { - setStripeIntent(null) - onFatal(it) - } - ) - } + internal fun maybeFetchStripeIntent() = if (stripeIntent.value == null) { + viewModelScope.launch { + runCatching { + stripeIntentRepository.get(args.clientSecret) + }.fold( + onSuccess = ::onStripeIntentFetchResponse, + onFailure = { + setStripeIntent(null) + onFatal(it) + } + ) } + true + } else { + false } private fun onStripeIntentFetchResponse(stripeIntent: StripeIntent) { @@ -217,6 +223,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( stripeIntentValidator.requireValid(stripeIntent) }.fold( onSuccess = { + savedStateHandle.set(SAVE_STRIPE_INTENT, stripeIntent) updatePaymentMethods(stripeIntent) resetViewState() }, @@ -256,7 +263,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( }.orEmpty() }.fold( onSuccess = { - _paymentMethods.value = it + savedStateHandle.set(SAVE_PAYMENT_METHODS, it) setStripeIntent(stripeIntent) resetViewState() }, @@ -274,7 +281,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( private fun resetViewState(userErrorMessage: String? = null) { _viewState.value = PaymentSheetViewState.Reset(userErrorMessage?.let { UserErrorMessage(it) }) - _processing.value = false + savedStateHandle.set(SAVE_PROCESSING, false) } fun checkout(checkoutIdentifier: CheckoutIdentifier) { @@ -284,7 +291,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( } this.checkoutIdentifier = checkoutIdentifier - _processing.value = true + savedStateHandle.set(SAVE_PROCESSING, true) _viewState.value = PaymentSheetViewState.StartProcessing val paymentSelection = selection.value @@ -460,8 +467,11 @@ internal class PaymentSheetViewModel @Inject internal constructor( internal class Factory( private val applicationSupplier: () -> Application, - private val starterArgsSupplier: () -> PaymentSheetContract.Args - ) : ViewModelProvider.Factory, Injectable { + private val starterArgsSupplier: () -> PaymentSheetContract.Args, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null + ) : AbstractSavedStateViewModelFactory(owner, defaultArgs), + Injectable { internal data class FallbackInitializeParam( val application: Application, ) @@ -471,12 +481,19 @@ internal class PaymentSheetViewModel @Inject internal constructor( Provider @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { + override fun create( + key: String, + modelClass: Class, + savedStateHandle: SavedStateHandle + ): T { val args = starterArgsSupplier() + injectWithFallback(args.injectorKey, FallbackInitializeParam(applicationSupplier())) - return subComponentBuilderProvider.get().paymentSheetViewModelModule( - PaymentSheetViewModelModule(args) - ).build().viewModel as T + + return subComponentBuilderProvider.get() + .paymentSheetViewModelModule(PaymentSheetViewModelModule(args)) + .savedStateHandle(savedStateHandle) + .build().viewModel as T } override fun fallbackInitialize(arg: FallbackInitializeParam) { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentOptionsViewModelSubcomponent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentOptionsViewModelSubcomponent.kt index 9810adca40a..8869e0475c0 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentOptionsViewModelSubcomponent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentOptionsViewModelSubcomponent.kt @@ -1,6 +1,7 @@ package com.stripe.android.paymentsheet.injection import android.app.Application +import androidx.lifecycle.SavedStateHandle import com.stripe.android.paymentsheet.PaymentOptionContract import com.stripe.android.paymentsheet.PaymentOptionsViewModel import dagger.BindsInstance @@ -15,6 +16,9 @@ internal interface PaymentOptionsViewModelSubcomponent { @BindsInstance fun application(application: Application): Builder + @BindsInstance + fun savedStateHandle(handle: SavedStateHandle): Builder + @BindsInstance fun args(args: PaymentOptionContract.Args): Builder diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentSheetViewModelSubcomponent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentSheetViewModelSubcomponent.kt index ef60245392e..4862e14f502 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentSheetViewModelSubcomponent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/PaymentSheetViewModelSubcomponent.kt @@ -1,6 +1,8 @@ package com.stripe.android.paymentsheet.injection +import androidx.lifecycle.SavedStateHandle import com.stripe.android.paymentsheet.PaymentSheetViewModel +import dagger.BindsInstance import dagger.Subcomponent @Subcomponent( @@ -15,6 +17,9 @@ internal interface PaymentSheetViewModelSubcomponent { paymentSheetViewModelModule: PaymentSheetViewModelModule ): Builder + @BindsInstance + fun savedStateHandle(handle: SavedStateHandle): Builder + fun build(): PaymentSheetViewModelSubcomponent } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt index 0e847f907c1..c69400be2f7 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt @@ -86,7 +86,7 @@ internal sealed class SupportedPaymentMethod( /** * This describes how the UI should look. */ - val formSpec: LayoutSpec?, + val formSpec: LayoutSpec, ) : Parcelable { @Parcelize object Card : SupportedPaymentMethod( diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt index 8394a2bc9d2..4db877ab12b 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt @@ -10,6 +10,8 @@ import android.widget.CheckBox import android.widget.LinearLayout import android.widget.Space import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import androidx.core.view.isVisible @@ -18,9 +20,14 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.stripe.android.model.Address +import com.stripe.android.core.model.Country import com.stripe.android.core.model.CountryCode +import com.stripe.android.model.Address import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.paymentsheet.PaymentOptionContract +import com.stripe.android.paymentsheet.PaymentOptionsViewModel +import com.stripe.android.paymentsheet.PaymentSheetActivity +import com.stripe.android.paymentsheet.PaymentSheetViewModel import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddCardBinding import com.stripe.android.paymentsheet.databinding.StripeHorizontalDividerBinding @@ -30,22 +37,11 @@ import com.stripe.android.paymentsheet.ui.BillingAddressView import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel import com.stripe.android.view.CardInputListener import com.stripe.android.view.CardMultilineWidget -import com.stripe.android.core.model.Country /** * A [Fragment] for collecting data for a new card payment method. */ -internal class CardDataCollectionFragment>( - private val viewModelClass: Class, - private val viewModelFactory: ViewModelProvider.Factory -) : Fragment() { - // Because the ViewModel is a subclass of BaseSheetViewModel (depending on whether we're going - // through the complete or custom flow), we need to parameterize the ViewModel class so it is - // properly reused if it was already created. - val sheetViewModel: ViewModelType by lazy { - ViewModelProvider(requireActivity(), viewModelFactory).get(viewModelClass) - } - +internal class CardDataCollectionFragment : Fragment() { private lateinit var cardMultilineWidget: CardMultilineWidget private lateinit var billingAddressView: BillingAddressView private lateinit var cardErrors: TextView @@ -70,8 +66,49 @@ internal class CardDataCollectionFragment> } } + @VisibleForTesting + internal lateinit var sheetViewModel: BaseSheetViewModel<*> + private val addCardViewModel: AddCardViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (requireNotNull( + requireArguments().getParcelable(PaymentSheetActivity.EXTRA_STARTER_ARGS) + ) is PaymentOptionContract.Args + ) { + sheetViewModel = ViewModelProvider( + requireActivity(), + PaymentOptionsViewModel.Factory( + { requireActivity().application }, + { + requireNotNull( + requireArguments().getParcelable( + PaymentSheetActivity.EXTRA_STARTER_ARGS + ) + ) + }, + (activity as? AppCompatActivity) ?: this + ) + ).get(PaymentOptionsViewModel::class.java) + } else { + sheetViewModel = ViewModelProvider( + requireActivity(), + PaymentSheetViewModel.Factory( + { requireActivity().application }, + { + requireNotNull( + requireArguments().getParcelable( + PaymentSheetActivity.EXTRA_STARTER_ARGS + ) + ) + }, + (activity as? AppCompatActivity) ?: this + ) + ).get(PaymentSheetViewModel::class.java) + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -80,6 +117,7 @@ internal class CardDataCollectionFragment> val themedInflater = inflater.cloneInContext( ContextThemeWrapper(requireActivity(), R.style.StripePaymentSheetAddPaymentMethodTheme) ) + return themedInflater.inflate( R.layout.fragment_paymentsheet_add_card, container, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethodsFragmentFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethodsFragmentFactory.kt deleted file mode 100644 index 900f80becb6..00000000000 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethodsFragmentFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.stripe.android.paymentsheet.ui - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentFactory -import androidx.lifecycle.ViewModelProvider -import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment -import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel - -/** - * [FragmentFactory] for fragments used to add new payment methods. - */ -internal class AddPaymentMethodsFragmentFactory>( - private val viewModelClass: Class, - private val viewModelFactory: ViewModelProvider.Factory -) : FragmentFactory() { - override fun instantiate(classLoader: ClassLoader, className: String): Fragment { - return when (className) { - CardDataCollectionFragment::class.java.name -> { - CardDataCollectionFragment(viewModelClass, viewModelFactory) - } - else -> super.instantiate(classLoader, className) - } - } -} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/BaseSheetActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/BaseSheetActivity.kt index c4c3ae16e05..bf4a31c1465 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/BaseSheetActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/BaseSheetActivity.kt @@ -5,8 +5,11 @@ import android.content.pm.ActivityInfo import android.graphics.Insets import android.os.Build import android.os.Bundle +import android.util.DisplayMetrics import android.view.View import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowMetrics import android.widget.ScrollView import android.widget.TextView import androidx.annotation.DrawableRes @@ -21,10 +24,7 @@ import com.stripe.android.paymentsheet.BottomSheetController import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel import com.stripe.android.view.KeyboardController -import android.util.DisplayMetrics import kotlin.math.roundToInt -import android.view.WindowInsets -import android.view.WindowMetrics internal abstract class BaseSheetActivity : AppCompatActivity() { abstract val viewModel: BaseSheetViewModel<*> @@ -52,10 +52,8 @@ internal abstract class BaseSheetActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - supportFragmentManager.fragmentFactory = - PaymentSheetFragmentFactory(viewModel.eventReporter) - super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O) { // In Oreo, Activities where `android:windowIsTranslucent=true` can't request // orientation. See https://stackoverflow.com/a/50832408/11103900 diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetFragmentFactory.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetFragmentFactory.kt deleted file mode 100644 index 54394f7adca..00000000000 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/PaymentSheetFragmentFactory.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.stripe.android.paymentsheet.ui - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentFactory -import com.stripe.android.paymentsheet.PaymentOptionsAddPaymentMethodFragment -import com.stripe.android.paymentsheet.PaymentOptionsListFragment -import com.stripe.android.paymentsheet.PaymentSheetAddPaymentMethodFragment -import com.stripe.android.paymentsheet.PaymentSheetListFragment -import com.stripe.android.paymentsheet.analytics.EventReporter - -internal class PaymentSheetFragmentFactory( - private val eventReporter: EventReporter -) : FragmentFactory() { - override fun instantiate(classLoader: ClassLoader, className: String): Fragment { - return when (className) { - PaymentOptionsListFragment::class.java.name -> { - PaymentOptionsListFragment(eventReporter) - } - PaymentSheetListFragment::class.java.name -> { - PaymentSheetListFragment(eventReporter) - } - PaymentSheetAddPaymentMethodFragment::class.java.name -> { - PaymentSheetAddPaymentMethodFragment(eventReporter) - } - PaymentOptionsAddPaymentMethodFragment::class.java.name -> { - PaymentOptionsAddPaymentMethodFragment(eventReporter) - } - else -> { - super.instantiate(classLoader, className) - } - } - } -} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt index 028cdc64f7e..bf9f128050c 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt @@ -6,15 +6,16 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.asFlow import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import com.stripe.android.core.Logger +import com.stripe.android.core.injection.InjectorKey import com.stripe.android.model.PaymentIntent import com.stripe.android.model.PaymentMethod import com.stripe.android.model.StripeIntent -import com.stripe.android.core.injection.InjectorKey import com.stripe.android.paymentsheet.BaseAddPaymentMethodFragment import com.stripe.android.paymentsheet.BasePaymentMethodsListFragment import com.stripe.android.paymentsheet.PaymentOptionsActivity @@ -22,14 +23,14 @@ import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.paymentsheet.PaymentSheetActivity import com.stripe.android.paymentsheet.PrefsRepository import com.stripe.android.paymentsheet.analytics.EventReporter -import com.stripe.android.ui.core.forms.resources.ResourceRepository -import com.stripe.android.ui.core.Amount import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SavedSelection import com.stripe.android.paymentsheet.model.SupportedPaymentMethod import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.repositories.CustomerRepository +import com.stripe.android.ui.core.Amount +import com.stripe.android.ui.core.forms.resources.ResourceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -50,7 +51,8 @@ internal abstract class BaseSheetViewModel( protected val workContext: CoroutineContext = Dispatchers.IO, protected val logger: Logger, @InjectorKey val injectorKey: String, - resourceRepository: ResourceRepository + resourceRepository: ResourceRepository, + val savedStateHandle: SavedStateHandle ) : AndroidViewModel(application) { internal val customerConfig = config?.customer internal val merchantName = config?.merchantDisplayName @@ -60,20 +62,29 @@ internal abstract class BaseSheetViewModel( protected val _fatal = MutableLiveData() @VisibleForTesting - internal val _isGooglePayReady = MutableLiveData() + internal val _isGooglePayReady = savedStateHandle.getLiveData( + SAVE_GOOGLE_PAY_READY + ) internal val isGooglePayReady: LiveData = _isGooglePayReady.distinctUntilChanged() - private val _isResourceRepositoryReady = MutableLiveData() + private val _isResourceRepositoryReady = savedStateHandle.getLiveData( + SAVE_RESOURCE_REPOSITORY_READY + ) internal val isResourceRepositoryReady: LiveData = _isResourceRepositoryReady.distinctUntilChanged() - private val _stripeIntent = MutableLiveData() + private val _stripeIntent = savedStateHandle.getLiveData(SAVE_STRIPE_INTENT) internal val stripeIntent: LiveData = _stripeIntent - internal var supportedPaymentMethods = emptyList() + internal var supportedPaymentMethods + get() = savedStateHandle.get>( + SAVE_SUPPORTED_PAYMENT_METHOD + ) ?: emptyList() + set(value) = savedStateHandle.set(SAVE_SUPPORTED_PAYMENT_METHOD, value) @VisibleForTesting - internal val _paymentMethods = MutableLiveData>() + internal val _paymentMethods = + savedStateHandle.getLiveData>(SAVE_PAYMENT_METHODS) /** * The list of saved payment methods for the current customer. @@ -82,16 +93,19 @@ internal abstract class BaseSheetViewModel( internal val paymentMethods: LiveData> = _paymentMethods @VisibleForTesting - internal val _amount = MutableLiveData() + internal val _amount = savedStateHandle.getLiveData(SAVE_AMOUNT) internal val amount: LiveData = _amount + private var addFragmentSelectedLPM = + savedStateHandle.get(SAVE_SELECTED_ADD_LPM) + /** * Request to retrieve the value from the repository happens when initialize any fragment * and any fragment will re-update when the result comes back. * Represents what the user last selects (add or buy) on the * [PaymentOptionsActivity]/[PaymentSheetActivity], and saved/restored from the preferences. */ - private val _savedSelection = MutableLiveData() + private val _savedSelection = savedStateHandle.getLiveData(SAVE_SAVED_SELECTION) private val savedSelection: LiveData = _savedSelection private val _transition = MutableLiveData>(Event(null)) @@ -106,13 +120,14 @@ internal abstract class BaseSheetViewModel( * card fragment is determined to be valid (not necessarily selected) * On [BasePaymentMethodsListFragment] this is set when a user selects one of the options */ - private val _selection = MutableLiveData() + private val _selection = savedStateHandle.getLiveData(SAVE_SELECTION) + internal val selection: LiveData = _selection private val editing = MutableLiveData(false) @VisibleForTesting - internal val _processing = MutableLiveData(true) + internal val _processing = savedStateHandle.getLiveData(SAVE_PROCESSING) val processing: LiveData = _processing /** @@ -142,16 +157,20 @@ internal abstract class BaseSheetViewModel( }.distinctUntilChanged() init { - viewModelScope.launch { - val savedSelection = withContext(workContext) { - prefsRepository.getSavedSelection(isGooglePayReady.asFlow().first()) + if (_savedSelection.value == null) { + viewModelScope.launch { + val savedSelection = withContext(workContext) { + prefsRepository.getSavedSelection(isGooglePayReady.asFlow().first()) + } + savedStateHandle.set(SAVE_SAVED_SELECTION, savedSelection) } - _savedSelection.value = savedSelection } - viewModelScope.launch { - resourceRepository.waitUntilLoaded() - _isResourceRepositoryReady.value = true + if (_isResourceRepositoryReady.value == null) { + viewModelScope.launch { + resourceRepository.waitUntilLoaded() + savedStateHandle.set(SAVE_RESOURCE_REPOSITORY_READY, true) + } } } @@ -203,15 +222,18 @@ internal abstract class BaseSheetViewModel( @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) fun setStripeIntent(stripeIntent: StripeIntent?) { - _stripeIntent.value = stripeIntent + savedStateHandle.set(SAVE_STRIPE_INTENT, stripeIntent) /** * The settings of values in this function is so that * they will be ready in the onViewCreated method of * the [BaseAddPaymentMethodFragment] */ - - supportedPaymentMethods = SupportedPaymentMethod.getPMsToAdd(stripeIntent, config) + val pmsToAdd = SupportedPaymentMethod.getPMsToAdd(stripeIntent, config) + savedStateHandle.set( + SAVE_SUPPORTED_PAYMENT_METHOD, + pmsToAdd + ) if (stripeIntent != null && supportedPaymentMethods.isEmpty()) { onFatal( @@ -226,11 +248,13 @@ internal abstract class BaseSheetViewModel( if (stripeIntent is PaymentIntent) { runCatching { - _amount.value = + savedStateHandle.set( + SAVE_AMOUNT, Amount( requireNotNull(stripeIntent.amount), requireNotNull(stripeIntent.currency) ) + ) }.onFailure { onFatal( IllegalStateException("PaymentIntent must contain amount and currency.") @@ -260,9 +284,17 @@ internal abstract class BaseSheetViewModel( } fun updateSelection(selection: PaymentSelection?) { - _selection.value = selection + savedStateHandle.set(SAVE_SELECTION, selection) } + fun setAddFragmentSelectedLPM(lpm: SupportedPaymentMethod) { + savedStateHandle.set(SAVE_SELECTED_ADD_LPM, lpm) + } + + fun getAddFragmentSelectedLPM() = + savedStateHandle.get(SAVE_SELECTED_ADD_LPM) + ?: SupportedPaymentMethod.Card + fun setEditing(isEditing: Boolean) { editing.value = isEditing } @@ -270,9 +302,12 @@ internal abstract class BaseSheetViewModel( fun removePaymentMethod(paymentMethod: PaymentMethod) = runBlocking { launch { paymentMethod.id?.let { paymentMethodId -> - _paymentMethods.value = _paymentMethods.value?.filter { - it.id != paymentMethodId - } + savedStateHandle.set( + SAVE_PAYMENT_METHODS, + _paymentMethods.value?.filter { + it.id != paymentMethodId + } + ) customerConfig?.let { customerRepository.detachPaymentMethod( @@ -316,4 +351,17 @@ internal abstract class BaseSheetViewModel( @TestOnly fun peekContent(): T = content } + + companion object { + internal const val SAVE_STRIPE_INTENT = "stripe_intent" + internal const val SAVE_PAYMENT_METHODS = "customer_payment_methods" + internal const val SAVE_AMOUNT = "amount" + internal const val SAVE_SELECTED_ADD_LPM = "selected_add_lpm" + internal const val SAVE_SELECTION = "selection" + internal const val SAVE_SAVED_SELECTION = "saved_selection" + internal const val SAVE_SUPPORTED_PAYMENT_METHOD = "supported_payment_methods" + internal const val SAVE_PROCESSING = "processing" + internal const val SAVE_GOOGLE_PAY_READY = "google_pay_ready" + internal const val SAVE_RESOURCE_REPOSITORY_READY = "resource_repository_ready" + } } 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 a81af358eb8..3b9b6814d5b 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsActivityTest.kt @@ -5,6 +5,7 @@ import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.common.truth.Truth.assertThat @@ -13,9 +14,9 @@ import com.stripe.android.PaymentConfiguration import com.stripe.android.R import com.stripe.android.core.Logger import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY -import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.model.PaymentMethodFixtures import com.stripe.android.paymentsheet.PaymentOptionsViewModel.TransitionTarget +import com.stripe.android.paymentsheet.PaymentSheetFixtures.PAYMENT_OPTIONS_CONTRACT_ARGS import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.PrimaryButtonBinding import com.stripe.android.paymentsheet.model.PaymentSelection @@ -34,6 +35,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.robolectric.RobolectricTestRunner import kotlin.test.BeforeTest @@ -303,21 +305,8 @@ class PaymentOptionsActivityTest { AddressFieldElementRepository( ApplicationProvider.getApplicationContext().resources ) - ) - ) - } - - private companion object { - private val PAYMENT_OPTIONS_CONTRACT_ARGS = PaymentOptionContract.Args( - stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, - paymentMethods = emptyList(), - config = PaymentSheetFixtures.CONFIG_GOOGLEPAY, - isGooglePayReady = false, - newCard = null, - statusBarColor = PaymentSheetFixtures.STATUS_BAR_COLOR, - injectorKey = DUMMY_INJECTOR_KEY, - enableLogging = false, - productUsage = mock() + ), + savedStateHandle = SavedStateHandle() ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt index a29340493fe..176f6967cb9 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt @@ -1,5 +1,6 @@ package com.stripe.android.paymentsheet +import android.app.Application import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.testing.launchFragmentInContainer @@ -7,26 +8,26 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.PaymentConfiguration -import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY import com.stripe.android.core.injection.WeakMapInjectorRegistry -import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddPaymentMethodBinding import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.FragmentConfigFixtures -import com.stripe.android.paymentsheet.ui.PaymentSheetFragmentFactory +import com.stripe.android.utils.TestUtils import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.robolectric.RobolectricTestRunner @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) -class PaymentOptionsAddPaymentMethodFragmentTest { - private val eventReporter = mock() +internal class PaymentOptionsAddPaymentMethodFragmentTest : PaymentOptionsViewModelTestInjection() { @Before fun setup() { @@ -38,17 +39,70 @@ class PaymentOptionsAddPaymentMethodFragmentTest { @After fun cleanUp() { - WeakMapInjectorRegistry.staticCacheMap.clear() + super.after() } @Test fun `when isGooglePayEnabled=true should still not display the Google Pay button`() { - createFragment { _, viewBinding -> + createFragment { _, viewBinding, _ -> assertThat(viewBinding.googlePayButton.isVisible) .isFalse() } } + @Test + fun `Factory gets initialized by Injector when Injector is available`() { + createFragment { fragment, _, viewModel -> + val factory = PaymentOptionsViewModel.Factory( + { ApplicationProvider.getApplicationContext() }, + { + PaymentOptionContract.Args( + PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, + emptyList(), + null, + false, + null, + null, + DUMMY_INJECTOR_KEY, + false, + mock() + ) + }, + fragment + ) + assertThat(fragment.sheetViewModel).isEqualTo(viewModel) + + WeakMapInjectorRegistry.clear() + } + } + + @Test + fun `Factory gets initialized with fallback when no Injector is available`() = runBlockingTest { + createFragment(registerInjector = false) { fragment, _, viewModel -> + val context = ApplicationProvider.getApplicationContext() + val productUsage = setOf("TestProductUsage") + PaymentConfiguration.init(context, "testKey") + val factory = PaymentOptionsViewModel.Factory( + { context }, + { + PaymentOptionContract.Args( + PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, + emptyList(), + null, + false, + null, + null, + DUMMY_INJECTOR_KEY, + false, + productUsage + ) + }, + fragment + ) + assertThat(fragment.sheetViewModel).isNotEqualTo(viewModel) + } + } + private fun createFragment( args: PaymentOptionContract.Args = PaymentOptionContract.Args( stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, @@ -62,21 +116,33 @@ class PaymentOptionsAddPaymentMethodFragmentTest { productUsage = mock() ), fragmentConfig: FragmentConfig? = FragmentConfigFixtures.DEFAULT, - onReady: (PaymentOptionsAddPaymentMethodFragment, FragmentPaymentsheetAddPaymentMethodBinding) -> Unit + registerInjector: Boolean = true, + onReady: (PaymentOptionsAddPaymentMethodFragment, FragmentPaymentsheetAddPaymentMethodBinding, PaymentOptionsViewModel) -> Unit ) { + assertThat(WeakMapInjectorRegistry.staticCacheMap.size).isEqualTo(0) + val viewModel = createViewModel( + paymentMethods = args.paymentMethods, + injectorKey = args.injectorKey, + args = args + ) + viewModel.setStripeIntent(args.stripeIntent) + TestUtils.idleLooper() + if (registerInjector) { + registerViewModel(args.injectorKey, viewModel) + } launchFragmentInContainer( bundleOf( PaymentOptionsActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, PaymentOptionsActivity.EXTRA_STARTER_ARGS to args ), R.style.StripePaymentSheetDefaultTheme, - factory = PaymentSheetFragmentFactory(eventReporter) ).onFragment { fragment -> onReady( fragment, FragmentPaymentsheetAddPaymentMethodBinding.bind( requireNotNull(fragment.view) - ) + ), + viewModel ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTest.kt index 494f3c889ec..91bdf7aad61 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTest.kt @@ -1,16 +1,13 @@ package com.stripe.android.paymentsheet -import android.app.Application import android.content.Context +import androidx.appcompat.app.AppCompatActivity import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat -import com.stripe.android.PaymentConfiguration import com.stripe.android.core.Logger import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY -import com.stripe.android.core.injection.Injectable -import com.stripe.android.core.injection.Injector -import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.model.PaymentMethodCreateParams @@ -18,7 +15,6 @@ import com.stripe.android.model.PaymentMethodCreateParamsFixtures.DEFAULT_CARD import com.stripe.android.model.PaymentMethodFixtures import com.stripe.android.paymentsheet.PaymentOptionsViewModel.TransitionTarget import com.stripe.android.paymentsheet.analytics.EventReporter -import com.stripe.android.paymentsheet.injection.PaymentOptionsViewModelSubcomponent import com.stripe.android.paymentsheet.model.FragmentConfigFixtures import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SavedSelection @@ -32,17 +28,11 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.argWhere import org.mockito.kotlin.mock -import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner -import javax.inject.Provider import kotlin.test.Test -import kotlin.test.assertNotNull @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) @@ -74,7 +64,8 @@ internal class PaymentOptionsViewModelTest { application = ApplicationProvider.getApplicationContext(), logger = Logger.noop(), injectorKey = DUMMY_INJECTOR_KEY, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = SavedStateHandle() ) @Test @@ -150,7 +141,8 @@ internal class PaymentOptionsViewModelTest { application = ApplicationProvider.getApplicationContext(), logger = Logger.noop(), injectorKey = DUMMY_INJECTOR_KEY, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = SavedStateHandle() ) var transitionTarget: BaseSheetViewModel.Event? = null @@ -180,7 +172,8 @@ internal class PaymentOptionsViewModelTest { application = ApplicationProvider.getApplicationContext(), logger = Logger.noop(), injectorKey = DUMMY_INJECTOR_KEY, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = SavedStateHandle() ) val transitionTarget = mutableListOf>() @@ -210,7 +203,8 @@ internal class PaymentOptionsViewModelTest { application = ApplicationProvider.getApplicationContext(), logger = Logger.noop(), injectorKey = DUMMY_INJECTOR_KEY, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = SavedStateHandle() ) val transitionTarget = mutableListOf>() @@ -240,7 +234,8 @@ internal class PaymentOptionsViewModelTest { application = ApplicationProvider.getApplicationContext(), logger = Logger.noop(), injectorKey = DUMMY_INJECTOR_KEY, - resourceRepository = resourceRepository + resourceRepository = resourceRepository, + savedStateHandle = SavedStateHandle() ) viewModel.removePaymentMethod(cards[1]) @@ -250,79 +245,6 @@ internal class PaymentOptionsViewModelTest { .containsExactly(cards[0], cards[2]) } - @Test - fun `Factory gets initialized by Injector when Injector is available`() { - val mockBuilder = mock() - val mockSubcomponent = mock() - val mockViewModel = mock() - - whenever(mockBuilder.build()).thenReturn(mockSubcomponent) - whenever(mockBuilder.application(any())).thenReturn(mockBuilder) - whenever(mockBuilder.args(any())).thenReturn(mockBuilder) - whenever(mockSubcomponent.viewModel).thenReturn(mockViewModel) - - val injector = object : Injector { - override fun inject(injectable: Injectable<*>) { - val factory = injectable as PaymentOptionsViewModel.Factory - factory.subComponentBuilderProvider = Provider { mockBuilder } - } - } - val injectorKey = WeakMapInjectorRegistry.nextKey("testKey") - WeakMapInjectorRegistry.register(injector, injectorKey) - val factory = PaymentOptionsViewModel.Factory( - { ApplicationProvider.getApplicationContext() }, - { - PaymentOptionContract.Args( - mock(), - mock(), - null, - false, - null, - null, - injectorKey, - false, - mock() - ) - } - ) - val factorySpy = spy(factory) - val createdViewModel = factorySpy.create(PaymentOptionsViewModel::class.java) - verify(factorySpy, times(0)).fallbackInitialize(any()) - assertThat(createdViewModel).isEqualTo(mockViewModel) - - WeakMapInjectorRegistry.staticCacheMap.clear() - } - - @Test - fun `Factory gets initialized with fallback when no Injector is available`() = runTest { - val context = ApplicationProvider.getApplicationContext() - val productUsage = setOf("TestProductUsage") - PaymentConfiguration.init(context, "testKey") - val factory = PaymentOptionsViewModel.Factory( - { context }, - { - PaymentOptionContract.Args( - mock(), - mock(), - null, - false, - null, - null, - DUMMY_INJECTOR_KEY, - false, - productUsage - ) - } - ) - val factorySpy = spy(factory) - assertNotNull(factorySpy.create(PaymentOptionsViewModel::class.java)) - verify(factorySpy).fallbackInitialize( - argWhere { - it.application == context && it.productUsage == productUsage - } - ) - } - private companion object { private val SELECTION_SAVED_PAYMENT_METHOD = PaymentSelection.Saved( PaymentMethodFixtures.CARD_PAYMENT_METHOD @@ -359,4 +281,6 @@ internal class PaymentOptionsViewModelTest { private val PAYMENT_METHOD_REPOSITORY_PARAMS = listOf(PaymentMethodFixtures.CARD_PAYMENT_METHOD) } + + private class MyHostActivity : AppCompatActivity() } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt new file mode 100644 index 00000000000..f3ff9a135f5 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt @@ -0,0 +1,89 @@ +package com.stripe.android.paymentsheet + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import com.stripe.android.core.Logger +import com.stripe.android.core.injection.Injectable +import com.stripe.android.core.injection.Injector +import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.core.injection.WeakMapInjectorRegistry +import com.stripe.android.model.PaymentMethod +import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.paymentsheet.injection.PaymentOptionsViewModelSubcomponent +import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Rule +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import javax.inject.Provider + +@ExperimentalCoroutinesApi +internal open class PaymentOptionsViewModelTestInjection { + @get:Rule + val rule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + val eventReporter = mock() + + private lateinit var injector: Injector + + @After + open fun after() { + WeakMapInjectorRegistry.clear() + } + + @ExperimentalCoroutinesApi + fun createViewModel( + paymentMethods: List = emptyList(), + @InjectorKey injectorKey: String, + args: PaymentOptionContract.Args = PaymentSheetFixtures.PAYMENT_OPTIONS_CONTRACT_ARGS + ): PaymentOptionsViewModel = runBlocking { + PaymentOptionsViewModel( + args, + prefsRepositoryFactory = { + FakePrefsRepository() + }, + eventReporter = eventReporter, + customerRepository = FakeCustomerRepository(paymentMethods), + workContext = testDispatcher, + application = ApplicationProvider.getApplicationContext(), + logger = Logger.noop(), + injectorKey = injectorKey, + resourceRepository = mock(), + savedStateHandle = SavedStateHandle().apply { + set(BaseSheetViewModel.SAVE_RESOURCE_REPOSITORY_READY, true) + } + ) + } + + fun registerViewModel( + @InjectorKey injectorKey: String, + viewModel: PaymentOptionsViewModel + ) { + val mockBuilder = mock() + val mockSubcomponent = mock() + val mockSubComponentBuilderProvider = mock>() + + whenever(mockBuilder.build()).thenReturn(mockSubcomponent) + whenever(mockBuilder.savedStateHandle(any())).thenReturn(mockBuilder) + whenever(mockBuilder.application(any())).thenReturn(mockBuilder) + whenever(mockBuilder.args(any())).thenReturn(mockBuilder) + whenever(mockSubcomponent.viewModel).thenReturn(viewModel) + whenever(mockSubComponentBuilderProvider.get()).thenReturn(mockBuilder) + + injector = object : Injector { + override fun inject(injectable: Injectable<*>) { + (injectable as? PaymentOptionsViewModel.Factory)?.let { + injectable.subComponentBuilderProvider = mockSubComponentBuilderProvider + } + } + } + WeakMapInjectorRegistry.register(injector, injectorKey) + } +} 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 b194500ffd1..0b443f88423 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt @@ -6,6 +6,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle +import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.common.truth.Truth.assertThat @@ -773,7 +774,8 @@ internal class PaymentSheetActivityTest { googlePayPaymentMethodLauncherFactory, Logger.noop(), testDispatcher, - DUMMY_INJECTOR_KEY + DUMMY_INJECTOR_KEY, + savedStateHandle = SavedStateHandle() ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt index 3040ac2229e..b1173e89ad9 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt @@ -1,25 +1,25 @@ package com.stripe.android.paymentsheet import android.content.Context -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.fragment.app.testing.FragmentScenario import androidx.fragment.app.testing.launchFragmentInContainer import androidx.lifecycle.Lifecycle import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.PaymentConfiguration -import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY +import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.model.PaymentIntent import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.model.PaymentIntentFixtures.PI_OFF_SESSION +import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodFixtures import com.stripe.android.model.StripeIntent import com.stripe.android.paymentsheet.PaymentSheetFixtures.COMPOSE_FRAGMENT_ARGS import com.stripe.android.paymentsheet.PaymentSheetViewModel.CheckoutIdentifier -import com.stripe.android.paymentsheet.analytics.EventReporter import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddPaymentMethodBinding import com.stripe.android.paymentsheet.databinding.PrimaryButtonBinding import com.stripe.android.paymentsheet.databinding.StripeGooglePayButtonBinding @@ -32,14 +32,15 @@ import com.stripe.android.paymentsheet.model.SupportedPaymentMethod import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.ComposeFormDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments -import com.stripe.android.paymentsheet.ui.PaymentSheetFragmentFactory import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.elements.IdentifierSpec import com.stripe.android.ui.core.forms.FormFieldEntry import com.stripe.android.utils.TestUtils.idleLooper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -48,12 +49,9 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +@ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) -class PaymentSheetAddPaymentMethodFragmentTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - private val eventReporter = mock() +internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelTestInjection() { private val context: Context = ApplicationProvider.getApplicationContext() @Before @@ -64,9 +62,14 @@ class PaymentSheetAddPaymentMethodFragmentTest { ) } + @After + override fun after() { + super.after() + } + @Test fun `when processing google pay should be disabled`() { - createFragment { fragment, viewBinding -> + createFragment { fragment, viewBinding, _ -> fragment.sheetViewModel._processing.value = true assertThat(viewBinding.googlePayButton.isEnabled).isFalse() } @@ -77,7 +80,8 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = mock().also { whenever(it.paymentMethodTypes).thenReturn(listOf("card", "bancontact")) } - createFragment(stripeIntent = paymentIntent) { fragment, viewBinding -> + createFragment(stripeIntent = paymentIntent) { fragment, viewBinding, _ -> + idleLooper() fragment.sheetViewModel._processing.value = true val adapter = viewBinding.paymentMethodsRecycler.adapter as AddPaymentMethodsAdapter @@ -91,7 +95,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = mock().also { whenever(it.paymentMethodTypes).thenReturn(listOf("card", "bancontact", "sofort", "ideal")) } - createFragment(stripeIntent = paymentIntent) { fragment, viewBinding -> + createFragment(stripeIntent = paymentIntent) { _, viewBinding, _ -> val item = viewBinding.paymentMethodsRecycler.layoutManager!!.findViewByPosition(0) assertThat(item!!.measuredWidth).isEqualTo(104) } @@ -103,7 +107,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = mock().also { whenever(it.paymentMethodTypes).thenReturn(listOf("card", "bancontact")) } - createFragment(stripeIntent = paymentIntent) { fragment, viewBinding -> + createFragment(stripeIntent = paymentIntent) { _, viewBinding, _ -> val item = viewBinding.paymentMethodsRecycler.layoutManager!!.findViewByPosition(0) assertThat(item!!.measuredWidth).isEqualTo(211) } @@ -111,7 +115,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when isGooglePayEnabled=true should configure Google Pay button`() { - createFragment { fragment, viewBinding -> + createFragment { fragment, viewBinding, _ -> val paymentSelections = mutableListOf() fragment.sheetViewModel.selection.observeForever { paymentSelection -> if (paymentSelection != null) { @@ -131,15 +135,21 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when back to Ready state should update PaymentSelection`() { - createFragment { fragment, viewBinding -> + createFragment( + paymentMethods = listOf(PaymentMethodFixtures.CARD_PAYMENT_METHOD) + ) { fragment, viewBinding, _ -> + fragment.sheetViewModel.savedStateHandle.set(BaseSheetViewModel.SAVE_PROCESSING, true) + + idleLooper() val paymentSelections = mutableListOf() fragment.sheetViewModel.selection.observeForever { paymentSelection -> paymentSelections.add(paymentSelection) } assertThat(viewBinding.googlePayButton.isVisible) - .isTrue() + .isFalse() + idleLooper() // Start with null PaymentSelection because the card entered is invalid assertThat(paymentSelections.size) .isEqualTo(1) @@ -166,14 +176,15 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `started fragment should report onShowNewPaymentOptionForm() event`() { - createFragment { _, _ -> + createFragment { _, _, _ -> + idleLooper() verify(eventReporter).onShowNewPaymentOptionForm() } } @Test fun `google pay button state updated on start processing`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> fragment.sheetViewModel.checkoutIdentifier = CheckoutIdentifier.AddFragmentTopGooglePay fragment.sheetViewModel._viewState.value = PaymentSheetViewState.StartProcessing @@ -192,7 +203,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `google pay button error message displayed`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> fragment.sheetViewModel.checkoutIdentifier = CheckoutIdentifier.AddFragmentTopGooglePay fragment.sheetViewModel._viewState.value = PaymentSheetViewState.Reset(BaseSheetViewModel.UserErrorMessage("This is my test error message")) @@ -208,7 +219,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `google pay flow updates the scroll view before and after`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> viewBinding.googlePayButton.performClick() assertThat(fragment.sheetViewModel._contentVisible.value).isEqualTo(false) @@ -220,7 +231,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `google pay button state updated on finish processing`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> fragment.sheetViewModel.checkoutIdentifier = CheckoutIdentifier.AddFragmentTopGooglePay var finishProcessingCalled = false @@ -242,7 +253,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when Google Pay is cancelled then previously selected payment method is selected again`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> val lastPaymentMethod = PaymentSelection.Saved(PaymentMethodFixtures.CARD_PAYMENT_METHOD) fragment.sheetViewModel.updateSelection(lastPaymentMethod) @@ -259,7 +270,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when new payment method is selected then error message is cleared`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> viewBinding.googlePayButton.performClick() val errorMessage = "Error message" @@ -282,7 +293,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when checkout starts then error message is cleared`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding -> + createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY) { fragment, viewBinding, _ -> viewBinding.googlePayButton.performClick() val errorMessage = "Error message" @@ -306,7 +317,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = PaymentIntentFixtures.PI_SUCCEEDED.copy( paymentMethodTypes = listOf("card") ) - createFragment(stripeIntent = paymentIntent) { _, viewBinding -> + createFragment(stripeIntent = paymentIntent) { _, viewBinding, _ -> assertThat(viewBinding.paymentMethodsRecycler.isVisible).isFalse() assertThat(viewBinding.googlePayDivider.viewBinding.dividerText.text) .isEqualTo("Or pay with a card") @@ -318,7 +329,8 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = PaymentIntentFixtures.PI_SUCCEEDED.copy( paymentMethodTypes = listOf("card", "bancontact") ) - createFragment(stripeIntent = paymentIntent) { fragment, viewBinding -> + createFragment(stripeIntent = paymentIntent) { fragment, viewBinding, _ -> + idleLooper() assertThat( fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id @@ -326,7 +338,6 @@ class PaymentSheetAddPaymentMethodFragmentTest { ).isInstanceOf(CardDataCollectionFragment::class.java) fragment.onPaymentMethodSelected(SupportedPaymentMethod.Bancontact) - idleLooper() val addedFragment = fragment.childFragmentManager.findFragmentById( @@ -346,7 +357,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { showCheckboxControlledFields = false, merchantName = PaymentSheetFixtures.MERCHANT_DISPLAY_NAME, amount = createAmount(), - injectorKey = DUMMY_INJECTOR_KEY + injectorKey = "testInjectorKeyAddFragmentTest" ) ) }.recreate().onFragment { fragment -> @@ -369,7 +380,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { showCheckboxControlledFields = false, merchantName = PaymentSheetFixtures.MERCHANT_DISPLAY_NAME, amount = createAmount(), - injectorKey = DUMMY_INJECTOR_KEY + injectorKey = "testInjectorKeyAddFragmentTest" ) ) } @@ -380,7 +391,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { val paymentIntent = PaymentIntentFixtures.PI_SUCCEEDED.copy( paymentMethodTypes = listOf("card", "bancontact") ) - createFragment(stripeIntent = paymentIntent) { _, viewBinding -> + createFragment(stripeIntent = paymentIntent) { _, viewBinding, _ -> assertThat(viewBinding.paymentMethodsRecycler.isVisible).isTrue() assertThat(viewBinding.googlePayDivider.viewBinding.dividerText.text) .isEqualTo("Or pay using") @@ -394,7 +405,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { createFragment( stripeIntent = stripeIntent, args = args - ) { fragment, viewBinding -> + ) { fragment, viewBinding, _ -> assertThat( fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id @@ -434,7 +445,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { createFragment( stripeIntent = stripeIntent, args = args - ) { fragment, viewBinding -> + ) { fragment, viewBinding, _ -> fragment.onPaymentMethodSelected(SupportedPaymentMethod.Card) idleLooper() @@ -464,7 +475,7 @@ class PaymentSheetAddPaymentMethodFragmentTest { @Test fun `when payment method selection changes then it's updated in ViewModel`() { - createFragment { fragment, viewBinding -> + createFragment { fragment, viewBinding, _ -> assertThat( fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id @@ -491,7 +502,8 @@ class PaymentSheetAddPaymentMethodFragmentTest { fun `when payment intent off session fragment parameters set correctly`() { val args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY val stripeIntent = PI_OFF_SESSION - createFragment(stripeIntent = stripeIntent, args = args) { fragment, viewBinding -> + createFragment(stripeIntent = stripeIntent, args = args) { fragment, viewBinding, _ -> + idleLooper() assertThat( fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id @@ -552,32 +564,62 @@ class PaymentSheetAddPaymentMethodFragmentTest { ) } + @Test + fun `Factory gets initialized by Injector when Injector is available`() { + createFragment(registerInjector = true) { fragment, _, viewModel -> + assertThat(fragment.sheetViewModel).isEqualTo(viewModel) + } + } + + @Test + fun `Factory gets initialized with fallback when no Injector is available`() = runBlockingTest { + createFragment(registerInjector = false) { fragment, _, viewModel -> + assertThat(fragment.sheetViewModel).isNotEqualTo(viewModel) + } + } + private fun createAmount(paymentIntent: PaymentIntent = PaymentIntentFixtures.PI_WITH_SHIPPING) = Amount(paymentIntent.amount!!, paymentIntent.currency!!) private fun createFragment( - args: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY, + args: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY.copy( + injectorKey = "testInjectorKeyAddFragmentTest" + ), fragmentConfig: FragmentConfig? = FragmentConfigFixtures.DEFAULT, + paymentMethods: List = emptyList(), stripeIntent: StripeIntent? = PaymentIntentFixtures.PI_WITH_SHIPPING, - onReady: (PaymentSheetAddPaymentMethodFragment, FragmentPaymentsheetAddPaymentMethodBinding) -> Unit - ) = launchFragmentInContainer( - bundleOf( - PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, - PaymentSheetActivity.EXTRA_STARTER_ARGS to args - ), - R.style.StripePaymentSheetDefaultTheme, - factory = PaymentSheetFragmentFactory(eventReporter), - initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> - // Mock sheetViewModel loading the StripeIntent before the Fragment is created - fragment.sheetViewModel.setStripeIntent(stripeIntent) - }.moveToState(Lifecycle.State.STARTED) - .onFragment { fragment -> + registerInjector: Boolean = true, + onReady: (PaymentSheetAddPaymentMethodFragment, FragmentPaymentsheetAddPaymentMethodBinding, PaymentSheetViewModel) -> Unit + ): FragmentScenario { + assertThat(WeakMapInjectorRegistry.staticCacheMap.size).isEqualTo(0) + val viewModel = createViewModel( + stripeIntent as PaymentIntent, + customerRepositoryPMs = paymentMethods, + injectorKey = args.injectorKey + ) + + return launchFragmentInContainer( + bundleOf( + PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, + PaymentSheetActivity.EXTRA_STARTER_ARGS to args + ), + R.style.StripePaymentSheetDefaultTheme, + initialState = Lifecycle.State.INITIALIZED + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> + viewModel.updatePaymentMethods(stripeIntent) + viewModel.setStripeIntent(stripeIntent) + idleLooper() + if (registerInjector) { + registerViewModel(args.injectorKey, viewModel) + } + }.moveToState(Lifecycle.State.STARTED).onFragment { fragment -> onReady( fragment, FragmentPaymentsheetAddPaymentMethodBinding.bind( requireNotNull(fragment.view) - ) + ), + viewModel ) } + } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetFixtures.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetFixtures.kt index 52d63de6367..467d94aa6e8 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetFixtures.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetFixtures.kt @@ -2,10 +2,12 @@ package com.stripe.android.paymentsheet import androidx.core.graphics.toColorInt import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY +import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.paymentsheet.model.PaymentIntentClientSecret import com.stripe.android.paymentsheet.model.SetupIntentClientSecret import com.stripe.android.paymentsheet.model.SupportedPaymentMethod import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments +import org.mockito.kotlin.mock internal object PaymentSheetFixtures { internal val STATUS_BAR_COLOR @@ -41,6 +43,18 @@ internal object PaymentSheetFixtures { googlePay = ConfigFixtures.GOOGLE_PAY ) + internal val PAYMENT_OPTIONS_CONTRACT_ARGS = PaymentOptionContract.Args( + stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD, + paymentMethods = emptyList(), + config = CONFIG_GOOGLEPAY, + isGooglePayReady = false, + newCard = null, + statusBarColor = STATUS_BAR_COLOR, + injectorKey = DUMMY_INJECTOR_KEY, + enableLogging = false, + productUsage = mock() + ) + internal val ARGS_CUSTOMER_WITH_GOOGLEPAY_SETUP get() = PaymentSheetContract.Args( SetupIntentClientSecret(CLIENT_SECRET), diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetListFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetListFragmentTest.kt index 45a4736e1c8..69f30a63b8c 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetListFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetListFragmentTest.kt @@ -2,7 +2,6 @@ package com.stripe.android.paymentsheet import android.os.Looper.getMainLooper import androidx.appcompat.app.AlertDialog -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.bundleOf import androidx.core.view.children import androidx.core.view.isVisible @@ -15,18 +14,20 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.PaymentConfiguration +import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.model.PaymentIntentFixtures +import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodFixtures -import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.model.SetupIntentFixtures import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetPaymentMethodsListBinding import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.FragmentConfigFixtures import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SavedSelection -import com.stripe.android.paymentsheet.ui.PaymentSheetFragmentFactory import com.stripe.android.utils.TestUtils.idleLooper +import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -37,11 +38,14 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowAlertDialog @RunWith(RobolectricTestRunner::class) -class PaymentSheetListFragmentTest { - @get:Rule - val rule = InstantTaskExecutorRule() +internal class PaymentSheetListFragmentTest : PaymentSheetViewModelTestInjection() { + @InjectorKey + private val injectorKey: String = "PaymentSheetListFragmentTest" - private val eventReporter = mock() + @After + override fun after() { + super.after() + } @Before fun setup() { @@ -62,7 +66,7 @@ class PaymentSheetListFragmentTest { savedSelection = SavedSelection.PaymentMethod(paymentMethod.id.orEmpty()) ), initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = listOf(paymentMethod) }.moveToState(Lifecycle.State.STARTED).onFragment { assertThat(activityViewModel(it).selection.value) @@ -86,7 +90,7 @@ class PaymentSheetListFragmentTest { savedSelection = SavedSelection.PaymentMethod(paymentMethod.id.orEmpty()) ), initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = listOf(paymentMethod) }.moveToState(Lifecycle.State.STARTED).onFragment { fragment -> assertThat(fragment.isEditing).isFalse() @@ -100,7 +104,7 @@ class PaymentSheetListFragmentTest { fun `sets up adapter`() { createScenario( initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS }.moveToState(Lifecycle.State.STARTED).onFragment { idleLooper() @@ -116,7 +120,7 @@ class PaymentSheetListFragmentTest { fun `when screen is 320dp wide, adapter should show 2 and a half items with 114dp width`() { createScenario( initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS }.moveToState(Lifecycle.State.STARTED).onFragment { val item = recyclerView(it).layoutManager!!.findViewByPosition(0) @@ -129,7 +133,7 @@ class PaymentSheetListFragmentTest { fun `when screen is 481dp wide, adapter should show 3 and a half items with 127dp width`() { createScenario( initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS }.moveToState(Lifecycle.State.STARTED).onFragment { val item = recyclerView(it).layoutManager!!.findViewByPosition(0) @@ -142,7 +146,7 @@ class PaymentSheetListFragmentTest { fun `when screen is 482dp wide, adapter should show 4 items with 112dp width`() { createScenario( initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> + ).moveToState(Lifecycle.State.CREATED).onFragment { fragment -> fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS }.moveToState(Lifecycle.State.STARTED).onFragment { val item = recyclerView(it).layoutManager!!.findViewByPosition(0) @@ -241,8 +245,8 @@ class PaymentSheetListFragmentTest { @Test fun `total amount label is hidden for SetupIntent`() { createScenario( - FRAGMENT_CONFIG, - PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY_SETUP + FRAGMENT_CONFIG.copy(stripeIntent = SetupIntentFixtures.SI_REQUIRES_PAYMENT_METHOD), + PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY_SETUP, ).onFragment { fragment -> shadowOf(getMainLooper()).idle() val viewBinding = FragmentPaymentsheetPaymentMethodsListBinding.bind(fragment.view!!) @@ -255,11 +259,9 @@ class PaymentSheetListFragmentTest { @Test fun `when config has saved payment methods then show options menu`() { createScenario( - initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> - fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS - }.moveToState(Lifecycle.State.STARTED).onFragment { fragment -> - idleLooper() + initialState = Lifecycle.State.INITIALIZED, + paymentMethods = PAYMENT_METHODS + ).moveToState(Lifecycle.State.STARTED).onFragment { fragment -> assertThat(fragment.hasOptionsMenu()).isTrue() } } @@ -267,10 +269,9 @@ class PaymentSheetListFragmentTest { @Test fun `when config does not have saved payment methods then show no options menu`() { createScenario( - initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> - fragment.sheetViewModel._paymentMethods.value = emptyList() - }.moveToState(Lifecycle.State.STARTED).onFragment { fragment -> + initialState = Lifecycle.State.INITIALIZED, + paymentMethods = emptyList() + ).moveToState(Lifecycle.State.STARTED).onFragment { fragment -> idleLooper() assertThat(fragment.hasOptionsMenu()).isFalse() } @@ -279,10 +280,9 @@ class PaymentSheetListFragmentTest { @Test fun `deletePaymentMethod() removes item from adapter`() { createScenario( - initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> - fragment.sheetViewModel._paymentMethods.value = PAYMENT_METHODS - }.moveToState(Lifecycle.State.STARTED).onFragment { fragment -> + initialState = Lifecycle.State.INITIALIZED, + paymentMethods = PAYMENT_METHODS + ).moveToState(Lifecycle.State.STARTED).onFragment { fragment -> idleLooper() val adapter = recyclerView(fragment).adapter as PaymentOptionsAdapter @@ -311,24 +311,43 @@ class PaymentSheetListFragmentTest { return fragment.activityViewModels { PaymentSheetViewModel.Factory( { fragment.requireActivity().application }, - { PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY } + { PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY }, + mock(), ) }.value } private fun createScenario( fragmentConfig: FragmentConfig? = FRAGMENT_CONFIG, - starterArgs: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY, - initialState: Lifecycle.State = Lifecycle.State.RESUMED, - ): FragmentScenario = launchFragmentInContainer( - bundleOf( - PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, - PaymentSheetActivity.EXTRA_STARTER_ARGS to starterArgs + starterArgs: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY.copy( + injectorKey = injectorKey ), - R.style.StripePaymentSheetDefaultTheme, - initialState = initialState, - factory = PaymentSheetFragmentFactory(eventReporter) - ) + initialState: Lifecycle.State = Lifecycle.State.RESUMED, + paymentMethods: List = listOf(PaymentMethodFixtures.CARD_PAYMENT_METHOD) + ): FragmentScenario { + assertThat(WeakMapInjectorRegistry.retrieve(injectorKey)).isNull() + fragmentConfig?.let { + createViewModel( + fragmentConfig.stripeIntent, + customerRepositoryPMs = paymentMethods, + injectorKey = starterArgs.injectorKey, + args = starterArgs + ).apply { + updatePaymentMethods(fragmentConfig.stripeIntent) + setStripeIntent(fragmentConfig.stripeIntent) + idleLooper() + registerViewModel(starterArgs.injectorKey, this) + } + } + return launchFragmentInContainer( + bundleOf( + PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, + PaymentSheetActivity.EXTRA_STARTER_ARGS to starterArgs + ), + R.style.StripePaymentSheetDefaultTheme, + initialState = initialState, + ) + } private companion object { private val PAYMENT_METHODS = listOf( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt index 221f2a154ab..60061a1604a 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt @@ -2,14 +2,16 @@ package com.stripe.android.paymentsheet import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider import com.google.android.gms.common.api.Status import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures -import com.stripe.android.core.Logger import com.stripe.android.PaymentConfiguration import com.stripe.android.PaymentIntentResult import com.stripe.android.StripeIntentResult +import com.stripe.android.core.Logger +import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.model.CardBrand import com.stripe.android.model.ConfirmPaymentIntentParams @@ -23,15 +25,10 @@ import com.stripe.android.model.PaymentMethodFixtures import com.stripe.android.model.SetupIntentFixtures import com.stripe.android.model.StripeIntent import com.stripe.android.networking.StripeRepository -import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY -import com.stripe.android.core.injection.Injectable -import com.stripe.android.core.injection.Injector -import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.payments.paymentlauncher.PaymentResult import com.stripe.android.paymentsheet.PaymentSheetViewModel.CheckoutIdentifier import com.stripe.android.paymentsheet.analytics.EventReporter -import com.stripe.android.paymentsheet.injection.PaymentSheetViewModelSubcomponent import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.PaymentSheetViewState @@ -58,22 +55,18 @@ import org.mockito.ArgumentMatchers.anyString import org.mockito.Captor import org.mockito.MockitoAnnotations import org.mockito.kotlin.any -import org.mockito.kotlin.argWhere import org.mockito.kotlin.capture import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.spy -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.util.Locale -import javax.inject.Provider import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertNotNull @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) @@ -789,6 +782,7 @@ internal class PaymentSheetViewModelTest { @Test fun `buyButton is only enabled when not processing, not editing, and a selection has been made`() { var isEnabled = false + viewModel.savedStateHandle.set(BaseSheetViewModel.SAVE_PROCESSING, true) viewModel.ctaEnabled.observeForever { isEnabled = it } @@ -797,6 +791,7 @@ internal class PaymentSheetViewModelTest { .isFalse() viewModel.updateSelection(PaymentSelection.GooglePay) + idleLooper() assertThat(isEnabled) .isFalse() @@ -838,63 +833,6 @@ internal class PaymentSheetViewModelTest { .isEqualTo("com.stripe.android.paymentsheet.test") } - @Test - fun `Factory gets initialized by Injector when Injector is available`() { - val mockBuilder = mock() - val mockSubComponent = mock() - val mockViewModel = mock() - - whenever(mockBuilder.build()).thenReturn(mockSubComponent) - whenever(mockBuilder.paymentSheetViewModelModule(any())).thenReturn(mockBuilder) - whenever((mockSubComponent.viewModel)).thenReturn(mockViewModel) - - val injector = object : Injector { - override fun inject(injectable: Injectable<*>) { - val factory = injectable as PaymentSheetViewModel.Factory - factory.subComponentBuilderProvider = Provider { mockBuilder } - } - } - val injectorKey = WeakMapInjectorRegistry.nextKey("testKey") - WeakMapInjectorRegistry.register(injector, injectorKey) - val factory = PaymentSheetViewModel.Factory( - { ApplicationProvider.getApplicationContext() }, - { - PaymentSheetContract.Args.createPaymentIntentArgsWithInjectorKey( - "testSecret", - injectorKey = injectorKey - ) - } - ) - val factorySpy = spy(factory) - val createdViewModel = factorySpy.create(PaymentSheetViewModel::class.java) - verify(factorySpy, times(0)).fallbackInitialize(any()) - assertThat(createdViewModel).isEqualTo(mockViewModel) - - WeakMapInjectorRegistry.staticCacheMap.clear() - } - - @Test - fun `Factory gets initialized with fallback when no Injector is available`() = runTest { - val context = ApplicationProvider.getApplicationContext() - PaymentConfiguration.init(context, ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) - val factory = PaymentSheetViewModel.Factory( - { context }, - { - PaymentSheetContract.Args.createPaymentIntentArgs( - "testSecret", - ) - } - ) - val factorySpy = spy(factory) - - assertNotNull(factorySpy.create(PaymentSheetViewModel::class.java)) - verify(factorySpy).fallbackInitialize( - argWhere { - it.application == context - } - ) - } - @Test fun `getSupportedPaymentMethods() filters payment methods with delayed settlement`() { val viewModel = createViewModel() @@ -972,7 +910,8 @@ internal class PaymentSheetViewModelTest { mock(), Logger.noop(), testDispatcher, - DUMMY_INJECTOR_KEY + DUMMY_INJECTOR_KEY, + savedStateHandle = SavedStateHandle() ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTestInjection.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTestInjection.kt new file mode 100644 index 00000000000..42c39ac1d5b --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTestInjection.kt @@ -0,0 +1,144 @@ +package com.stripe.android.paymentsheet + +import androidx.activity.result.ActivityResultLauncher +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import com.stripe.android.ApiKeyFixtures +import com.stripe.android.PaymentConfiguration +import com.stripe.android.core.Logger +import com.stripe.android.core.injection.Injectable +import com.stripe.android.core.injection.Injector +import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.core.injection.WeakMapInjectorRegistry +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher +import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContract +import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory +import com.stripe.android.model.PaymentMethod +import com.stripe.android.model.StripeIntent +import com.stripe.android.payments.paymentlauncher.StripePaymentLauncherAssistedFactory +import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.paymentsheet.forms.FormViewModel +import com.stripe.android.paymentsheet.injection.FormViewModelSubcomponent +import com.stripe.android.paymentsheet.injection.PaymentSheetViewModelSubcomponent +import com.stripe.android.paymentsheet.model.StripeIntentValidator +import com.stripe.android.paymentsheet.repositories.StripeIntentRepository +import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel +import com.stripe.android.ui.core.elements.LayoutSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Rule +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import javax.inject.Provider + +@ExperimentalCoroutinesApi +internal open class PaymentSheetViewModelTestInjection { + @get:Rule + val rule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + val eventReporter = mock() + private val googlePayPaymentMethodLauncherFactory = + createGooglePayPaymentMethodLauncherFactory() + private val stripePaymentLauncherAssistedFactory = + mock() + + private lateinit var injector: Injector + + @After + open fun after() { + WeakMapInjectorRegistry.clear() + } + + private fun createGooglePayPaymentMethodLauncherFactory() = + object : GooglePayPaymentMethodLauncherFactory { + override fun create( + lifecycleScope: CoroutineScope, + config: GooglePayPaymentMethodLauncher.Config, + readyCallback: GooglePayPaymentMethodLauncher.ReadyCallback, + activityResultLauncher: ActivityResultLauncher, + skipReadyCheck: Boolean + ): GooglePayPaymentMethodLauncher { + val googlePayPaymentMethodLauncher = mock() + readyCallback.onReady(true) + return googlePayPaymentMethodLauncher + } + } + + @ExperimentalCoroutinesApi + fun createViewModel( + stripeIntent: StripeIntent, + customerRepositoryPMs: List = emptyList(), + @InjectorKey injectorKey: String, + args: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY, + ): PaymentSheetViewModel = runBlocking { + PaymentSheetViewModel( + ApplicationProvider.getApplicationContext(), + args, + eventReporter, + { PaymentConfiguration(ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) }, + StripeIntentRepository.Static(stripeIntent), + StripeIntentValidator(), + FakeCustomerRepository(customerRepositoryPMs), + FakePrefsRepository(), + resourceRepository = mock(), + stripePaymentLauncherAssistedFactory, + googlePayPaymentMethodLauncherFactory, + Logger.noop(), + testDispatcher, + injectorKey, + savedStateHandle = SavedStateHandle().apply { + set(BaseSheetViewModel.SAVE_RESOURCE_REPOSITORY_READY, true) + } + ) + } + + @FlowPreview + fun registerViewModel( + @InjectorKey injectorKey: String, + viewModel: PaymentSheetViewModel, + formViewModel: FormViewModel = FormViewModel( + layout = LayoutSpec.create(), + config = mock(), + resourceRepository = mock(), + transformSpecToElement = mock() + ) + ) { + injector = object : Injector { + override fun inject(injectable: Injectable<*>) { + (injectable as? PaymentSheetViewModel.Factory)?.let { + val mockBuilder = mock() + val mockSubcomponent = mock() + val mockSubComponentBuilderProvider = mock>() + + whenever(mockBuilder.build()).thenReturn(mockSubcomponent) + whenever(mockBuilder.savedStateHandle(any())).thenReturn(mockBuilder) + whenever(mockBuilder.paymentSheetViewModelModule(any())).thenReturn(mockBuilder) + whenever(mockSubcomponent.viewModel).thenReturn(viewModel) + whenever(mockSubComponentBuilderProvider.get()).thenReturn(mockBuilder) + injectable.subComponentBuilderProvider = mockSubComponentBuilderProvider + } + (injectable as? FormViewModel.Factory)?.let { + val mockBuilder = mock() + val mockSubcomponent = mock() + val mockSubComponentBuilderProvider = mock>() + + whenever(mockBuilder.build()).thenReturn(mockSubcomponent) + whenever(mockBuilder.formFragmentArguments(any())).thenReturn(mockBuilder) + whenever(mockBuilder.layout(any())).thenReturn(mockBuilder) + whenever(mockSubcomponent.viewModel).thenReturn(formViewModel) + whenever(mockSubComponentBuilderProvider.get()).thenReturn(mockBuilder) + injectable.subComponentBuilderProvider = mockSubComponentBuilderProvider + } + } + } + WeakMapInjectorRegistry.register(injector, injectorKey) + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt index 12bef3960f7..a10badc63c5 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt @@ -103,7 +103,7 @@ internal class FormViewModelTest { verify(factorySpy, times(0)).fallbackInitialize(any()) assertThat(createdViewModel).isEqualTo(mockViewModel) - WeakMapInjectorRegistry.staticCacheMap.clear() + WeakMapInjectorRegistry.clear() } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt index 86027da7045..14ad022c630 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt @@ -9,8 +9,10 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures import com.stripe.android.PaymentConfiguration -import com.stripe.android.model.CardBrand +import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.core.model.CountryCode +import com.stripe.android.model.CardBrand +import com.stripe.android.model.PaymentIntent import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParamsFixtures @@ -19,22 +21,31 @@ import com.stripe.android.paymentsheet.PaymentSheetActivity import com.stripe.android.paymentsheet.PaymentSheetContract import com.stripe.android.paymentsheet.PaymentSheetFixtures import com.stripe.android.paymentsheet.PaymentSheetFixtures.COMPOSE_FRAGMENT_ARGS -import com.stripe.android.paymentsheet.PaymentSheetViewModel +import com.stripe.android.paymentsheet.PaymentSheetViewModelTestInjection import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddCardBinding import com.stripe.android.paymentsheet.databinding.StripeBillingAddressLayoutBinding +import com.stripe.android.paymentsheet.forms.FormViewModel +import com.stripe.android.paymentsheet.forms.TransformSpecToElement import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.FragmentConfigFixtures import com.stripe.android.paymentsheet.model.PaymentSelection -import com.stripe.android.paymentsheet.ui.AddPaymentMethodsFragmentFactory +import com.stripe.android.paymentsheet.model.SupportedPaymentMethod +import com.stripe.android.ui.core.Amount import com.stripe.android.utils.TestUtils.idleLooper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner +@ExperimentalCoroutinesApi +@FlowPreview @RunWith(RobolectricTestRunner::class) -class CardDataCollectionFragmentTest { +internal class CardDataCollectionFragmentTest : PaymentSheetViewModelTestInjection() { private val context: Context = ApplicationProvider.getApplicationContext() @Before @@ -45,6 +56,11 @@ class CardDataCollectionFragmentTest { ) } + @After + override fun after() { + super.after() + } + @Test fun `required billing fields should not be visible`() { createFragment { _, viewBinding -> @@ -445,33 +461,60 @@ class CardDataCollectionFragmentTest { fragmentConfig: FragmentConfig? = FragmentConfigFixtures.DEFAULT, stripeIntent: StripeIntent = PaymentIntentFixtures.PI_WITH_SHIPPING, newCard: PaymentSelection.New.Card? = null, - fragmentArgs: FormFragmentArguments? = COMPOSE_FRAGMENT_ARGS.copy( + fragmentArgs: FormFragmentArguments = FormFragmentArguments( + SupportedPaymentMethod.Card, + injectorKey = args.injectorKey, showCheckbox = true, showCheckboxControlledFields = true, + merchantName = args.config?.merchantDisplayName ?: "com.stripe.android.paymentsheet", + billingDetails = args.config?.defaultBillingDetails, + amount = (stripeIntent as? PaymentIntent)?.let { + Amount( + it.amount ?: 0, + it.currency ?: "usd", + ) + } ), - onReady: (CardDataCollectionFragment, FragmentPaymentsheetAddCardBinding) -> Unit + onReady: (CardDataCollectionFragment, FragmentPaymentsheetAddCardBinding) -> Unit ) { - val factory = AddPaymentMethodsFragmentFactory( - PaymentSheetViewModel::class.java, - PaymentSheetViewModel.Factory( - { ApplicationProvider.getApplicationContext() }, - { args } - ) + assertThat(WeakMapInjectorRegistry.staticCacheMap.size).isEqualTo(0) + val viewModel = createViewModel( + stripeIntent as PaymentIntent, + customerRepositoryPMs = emptyList(), + injectorKey = args.injectorKey, + args = args + ) + viewModel.newCard = newCard + idleLooper() + + val formFragmentArguments = FormFragmentArguments( + fragmentArgs.paymentMethod, + showCheckbox = fragmentArgs.showCheckbox, + showCheckboxControlledFields = fragmentArgs.showCheckboxControlledFields, + merchantName = fragmentArgs.merchantName, + amount = fragmentArgs.amount, + billingDetails = fragmentArgs.billingDetails, + injectorKey = args.injectorKey + ) + val formViewModel = FormViewModel( + layout = fragmentArgs.paymentMethod.formSpec, + config = formFragmentArguments, + resourceRepository = mock(), + transformSpecToElement = TransformSpecToElement(mock(), formFragmentArguments) ) - launchFragmentInContainer>( + + registerViewModel(args.injectorKey, viewModel, formViewModel) + + launchFragmentInContainer( bundleOf( PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, PaymentSheetActivity.EXTRA_STARTER_ARGS to args, ComposeFormDataCollectionFragment.EXTRA_CONFIG to fragmentArgs, ), R.style.StripePaymentSheetDefaultTheme, - factory = factory, initialState = Lifecycle.State.INITIALIZED - ).onFragment { fragment -> - // Mock sheetViewModel loading the StripeIntent before the Fragment is created - fragment.sheetViewModel.setStripeIntent(stripeIntent) - fragment.sheetViewModel.newCard = newCard - }.moveToState(Lifecycle.State.STARTED) + ) + .moveToState(Lifecycle.State.STARTED) .onFragment { fragment -> onReady( fragment, diff --git a/stripe-core/api/stripe-core.api b/stripe-core/api/stripe-core.api index 7d8a720b0a9..5006d5b181f 100644 --- a/stripe-core/api/stripe-core.api +++ b/stripe-core/api/stripe-core.api @@ -174,6 +174,7 @@ public abstract interface annotation class com/stripe/android/core/injection/UIC public final class com/stripe/android/core/injection/WeakMapInjectorRegistry : com/stripe/android/core/injection/InjectorRegistry { public static final field $stable I public static final field INSTANCE Lcom/stripe/android/core/injection/WeakMapInjectorRegistry; + public final fun clear ()V public final fun getCURRENT_REGISTER_KEY ()Ljava/util/concurrent/atomic/AtomicInteger; public final fun getStaticCacheMap ()Ljava/util/WeakHashMap; public fun nextKey (Ljava/lang/String;)Ljava/lang/String; diff --git a/stripe-core/src/main/java/com/stripe/android/core/injection/WeakMapInjectorRegistry.kt b/stripe-core/src/main/java/com/stripe/android/core/injection/WeakMapInjectorRegistry.kt index 30e928665a7..cec4e4f10e0 100644 --- a/stripe-core/src/main/java/com/stripe/android/core/injection/WeakMapInjectorRegistry.kt +++ b/stripe-core/src/main/java/com/stripe/android/core/injection/WeakMapInjectorRegistry.kt @@ -31,10 +31,12 @@ object WeakMapInjectorRegistry : InjectorRegistry { @VisibleForTesting val CURRENT_REGISTER_KEY = AtomicInteger(0) + @Synchronized override fun register(injector: Injector, @InjectorKey key: String) { staticCacheMap[injector] = key } + @Synchronized override fun retrieve(@InjectorKey injectorKey: String): Injector? { return staticCacheMap.entries.firstOrNull { it.value == injectorKey @@ -45,4 +47,10 @@ object WeakMapInjectorRegistry : InjectorRegistry { override fun nextKey(prefix: String): String { return prefix + CURRENT_REGISTER_KEY.incrementAndGet() } + + fun clear() { + synchronized(staticCacheMap) { + staticCacheMap.clear() + } + } }