diff --git a/link/api/link.api b/link/api/link.api index f1ade153b69..f8d6e41414e 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -87,11 +87,11 @@ public final class com/stripe/android/link/LinkActivityResult$Success : com/stri } public final class com/stripe/android/link/LinkActivityViewModel_Factory : dagger/internal/Factory { - public fun (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;)Lcom/stripe/android/link/LinkActivityViewModel_Factory; + public fun (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;)Lcom/stripe/android/link/LinkActivityViewModel_Factory; public fun get ()Lcom/stripe/android/link/LinkActivityViewModel; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Lcom/stripe/android/link/LinkActivityContract$Args;Lcom/stripe/android/link/account/LinkAccountManager;Lcom/stripe/android/link/account/CookieStore;Lcom/stripe/android/link/model/Navigator;Lcom/stripe/android/link/confirmation/ConfirmationManager;)Lcom/stripe/android/link/LinkActivityViewModel; + public static fun newInstance (Lcom/stripe/android/link/LinkActivityContract$Args;Lcom/stripe/android/link/account/LinkAccountManager;Lcom/stripe/android/link/model/Navigator;Lcom/stripe/android/link/confirmation/ConfirmationManager;)Lcom/stripe/android/link/LinkActivityViewModel; } public final class com/stripe/android/link/LinkActivityViewModel_Factory_MembersInjector : dagger/MembersInjector { @@ -105,8 +105,8 @@ public final class com/stripe/android/link/LinkActivityViewModel_Factory_Members public final class com/stripe/android/link/LinkPaymentLauncher_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/link/LinkPaymentLauncher_Factory; - public fun get (Landroidx/activity/result/ActivityResultLauncher;)Lcom/stripe/android/link/LinkPaymentLauncher; - public static fun newInstance (Landroidx/activity/result/ActivityResultLauncher;Landroid/content/Context;Ljava/util/Set;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLkotlin/coroutines/CoroutineContext;Lkotlin/coroutines/CoroutineContext;Lcom/stripe/android/networking/PaymentAnalyticsRequestFactory;Lcom/stripe/android/core/networking/AnalyticsRequestExecutor;Lcom/stripe/android/networking/StripeRepository;)Lcom/stripe/android/link/LinkPaymentLauncher; + public fun get (Ljava/lang/String;Ljava/lang/String;)Lcom/stripe/android/link/LinkPaymentLauncher; + public static fun newInstance (Ljava/lang/String;Ljava/lang/String;Landroid/content/Context;Ljava/util/Set;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLkotlin/coroutines/CoroutineContext;Lkotlin/coroutines/CoroutineContext;Lcom/stripe/android/networking/PaymentAnalyticsRequestFactory;Lcom/stripe/android/core/networking/AnalyticsRequestExecutor;Lcom/stripe/android/networking/StripeRepository;)Lcom/stripe/android/link/LinkPaymentLauncher; } public final class com/stripe/android/link/account/CookieStore_Factory : dagger/internal/Factory { @@ -126,11 +126,11 @@ public final class com/stripe/android/link/account/EncryptedStore_Factory : dagg } public final class com/stripe/android/link/account/LinkAccountManager_Factory : dagger/internal/Factory { - public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;)V - public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/link/account/LinkAccountManager_Factory; + public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/link/account/LinkAccountManager_Factory; public fun get ()Lcom/stripe/android/link/account/LinkAccountManager; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Lcom/stripe/android/link/repositories/LinkRepository;Lcom/stripe/android/link/account/CookieStore;)Lcom/stripe/android/link/account/LinkAccountManager; + public static fun newInstance (Lcom/stripe/android/link/LinkActivityContract$Args;Lcom/stripe/android/link/repositories/LinkRepository;Lcom/stripe/android/link/account/CookieStore;)Lcom/stripe/android/link/account/LinkAccountManager; } public final class com/stripe/android/link/confirmation/ConfirmationManager_Factory : dagger/internal/Factory { @@ -143,7 +143,9 @@ public final class com/stripe/android/link/confirmation/ConfirmationManager_Fact public final class com/stripe/android/link/injection/DaggerLinkPaymentLauncherComponent { public static fun builder ()Lcom/stripe/android/link/injection/LinkPaymentLauncherComponent$Builder; + public fun getLinkAccountManager ()Lcom/stripe/android/link/account/LinkAccountManager; public fun inject (Lcom/stripe/android/link/LinkActivityViewModel$Factory;)V + public fun inject (Lcom/stripe/android/link/ui/inline/InlineSignupViewModel$Factory;)V public fun inject (Lcom/stripe/android/link/ui/signup/SignUpViewModel$Factory;)V public fun inject (Lcom/stripe/android/link/ui/verification/VerificationViewModel$Factory;)V public fun inject (Lcom/stripe/android/link/ui/wallet/WalletViewModel$Factory;)V @@ -158,8 +160,8 @@ public final class com/stripe/android/link/injection/DaggerLinkViewModelFactoryC } public final class com/stripe/android/link/injection/LinkPaymentLauncherFactory_Impl : com/stripe/android/link/injection/LinkPaymentLauncherFactory { - public fun create (Landroidx/activity/result/ActivityResultLauncher;)Lcom/stripe/android/link/LinkPaymentLauncher; public static fun create (Lcom/stripe/android/link/LinkPaymentLauncher_Factory;)Ljavax/inject/Provider; + public fun create (Ljava/lang/String;Ljava/lang/String;)Lcom/stripe/android/link/LinkPaymentLauncher; } public final class com/stripe/android/link/injection/LinkPaymentLauncherModule_Companion_ProvideLocaleFactory : dagger/internal/Factory { @@ -170,6 +172,18 @@ public final class com/stripe/android/link/injection/LinkPaymentLauncherModule_C public static fun provideLocale ()Ljava/util/Locale; } +public final class com/stripe/android/link/injection/NamedConstantsKt { +} + +public final class com/stripe/android/link/model/AccountStatus : java/lang/Enum { + public static final field NeedsVerification Lcom/stripe/android/link/model/AccountStatus; + public static final field SignedOut Lcom/stripe/android/link/model/AccountStatus; + public static final field VerificationStarted Lcom/stripe/android/link/model/AccountStatus; + public static final field Verified Lcom/stripe/android/link/model/AccountStatus; + public static fun valueOf (Ljava/lang/String;)Lcom/stripe/android/link/model/AccountStatus; + public static fun values ()[Lcom/stripe/android/link/model/AccountStatus; +} + public final class com/stripe/android/link/model/Navigator_Factory : dagger/internal/Factory { public fun ()V public static fun create ()Lcom/stripe/android/link/model/Navigator_Factory; @@ -193,11 +207,24 @@ public final class com/stripe/android/link/ui/ComposableSingletons$LinkAppBarKt public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; } -public final class com/stripe/android/link/ui/ComposableSingletons$LinkButtonKt { - public static final field INSTANCE Lcom/stripe/android/link/ui/ComposableSingletons$LinkButtonKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; - public fun ()V - public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function3; +public final class com/stripe/android/link/ui/LinkTermsKt { + public static final fun LinkTerms-5stqomU (Landroidx/compose/ui/Modifier;ILandroidx/compose/runtime/Composer;II)V +} + +public final class com/stripe/android/link/ui/inline/InlineSignupViewModel_Factory : dagger/internal/Factory { + public fun (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;)Lcom/stripe/android/link/ui/inline/InlineSignupViewModel_Factory; + public fun get ()Lcom/stripe/android/link/ui/inline/InlineSignupViewModel; + public synthetic fun get ()Ljava/lang/Object; + public static fun newInstance (Ljava/lang/String;Ljava/lang/String;Lcom/stripe/android/link/account/LinkAccountManager;Lcom/stripe/android/core/Logger;)Lcom/stripe/android/link/ui/inline/InlineSignupViewModel; +} + +public final class com/stripe/android/link/ui/inline/InlineSignupViewModel_Factory_MembersInjector : dagger/MembersInjector { + public fun (Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;)Ldagger/MembersInjector; + public fun injectMembers (Lcom/stripe/android/link/ui/inline/InlineSignupViewModel$Factory;)V + public synthetic fun injectMembers (Ljava/lang/Object;)V + public static fun injectViewModel (Lcom/stripe/android/link/ui/inline/InlineSignupViewModel$Factory;Lcom/stripe/android/link/ui/inline/InlineSignupViewModel;)V } public final class com/stripe/android/link/ui/signup/ComposableSingletons$SignUpScreenKt { @@ -228,12 +255,14 @@ public final class com/stripe/android/link/ui/signup/SignUpViewModel_Factory_Mem public final class com/stripe/android/link/ui/verification/ComposableSingletons$VerificationScreenKt { public static final field INSTANCE Lcom/stripe/android/link/ui/verification/ComposableSingletons$VerificationScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function3; - public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-3$link_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function2; +} + +public final class com/stripe/android/link/ui/verification/VerificationDialogKt { + public static final fun LinkVerificationDialog (Lcom/stripe/android/link/LinkPaymentLauncher;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V } public final class com/stripe/android/link/ui/verification/VerificationViewModel_Factory : dagger/internal/Factory { diff --git a/link/res/values/strings.xml b/link/res/values/strings.xml index 5b877f6b248..bf2dcfb8377 100644 --- a/link/res/values/strings.xml +++ b/link/res/values/strings.xml @@ -4,13 +4,18 @@ Close + Save my info for secure 1-click checkout + Save your info for secure 1-click checkout Pay faster at %1$s and thousands of merchants. - You agree to Link terms of service and to create a Link with Stripe account. + By joining Link, you agree to the Terms and Privacy Policy. Join Link Enter your verification code Enter the code sent to %1$s to use Link to pay by default. + Sign in to your Link account + You’ve already saved your payment info with Link. Enter the code sent to %1$s. + Use your saved info to check out faster Not %1$s? Change email Resend code diff --git a/link/src/main/java/com/stripe/android/link/LinkActivity.kt b/link/src/main/java/com/stripe/android/link/LinkActivity.kt index 6d1d0467020..d575dab778a 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivity.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivity.kt @@ -2,6 +2,7 @@ package com.stripe.android.link import android.content.Intent import android.os.Bundle +import android.view.ViewTreeObserver import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -19,23 +20,32 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.ui.LinkAppBar import com.stripe.android.link.ui.signup.SignUpBody -import com.stripe.android.link.ui.verification.VerificationBody +import com.stripe.android.link.ui.verification.VerificationBodyFullFlow import com.stripe.android.link.ui.wallet.WalletBody +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch internal class LinkActivity : ComponentActivity() { + @VisibleForTesting + internal var viewModelFactory: ViewModelProvider.Factory = + LinkActivityViewModel.Factory( + applicationSupplier = { application }, + starterArgsSupplier = { requireNotNull(starterArgs) } + ) - private val viewModel: LinkActivityViewModel by viewModels { - LinkActivityViewModel.Factory(application) { requireNotNull(starterArgs) } - } + private val viewModel: LinkActivityViewModel by viewModels { viewModelFactory } @VisibleForTesting lateinit var navController: NavHostController @@ -66,7 +76,7 @@ internal class LinkActivity : ComponentActivity() { onCloseButtonClick = { dismiss() } ) - NavHost(navController, viewModel.startDestination) { + NavHost(navController, LinkScreen.Loading.route) { composable(LinkScreen.Loading.route) { Box( modifier = Modifier @@ -92,7 +102,7 @@ internal class LinkActivity : ComponentActivity() { } composable(LinkScreen.Verification.route) { linkAccount?.let { account -> - VerificationBody( + VerificationBodyFullFlow( account, viewModel.injector ) @@ -113,7 +123,7 @@ internal class LinkActivity : ComponentActivity() { .fillMaxHeight(), contentAlignment = Alignment.Center ) { - Text(text = "\nAdd new payment method") + Text(text = "\nAdd new payment method") } } } @@ -124,6 +134,25 @@ internal class LinkActivity : ComponentActivity() { viewModel.navigator.onDismiss = ::dismiss viewModel.setupPaymentLauncher(this) + + // Navigate to the initial screen once the view has been laid out. + window.decorView.rootView.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + lifecycleScope.launch { + viewModel.navigator.navigateTo( + target = when (viewModel.linkAccountManager.accountStatus.first()) { + AccountStatus.Verified -> LinkScreen.Wallet + AccountStatus.NeedsVerification, + AccountStatus.VerificationStarted -> LinkScreen.Verification + AccountStatus.SignedOut -> LinkScreen.SignUp() + }, + clearBackStack = true + ) + } + window.decorView.rootView.viewTreeObserver.removeOnGlobalLayoutListener(this) + } + }) } override fun onDestroy() { diff --git a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt index cf5d15ed335..e63412740fd 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivityViewModel.kt @@ -4,13 +4,11 @@ import android.app.Application import androidx.activity.result.ActivityResultCaller import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope import com.stripe.android.PaymentConfiguration import com.stripe.android.core.BuildConfig import com.stripe.android.core.Logger import com.stripe.android.core.injection.Injectable import com.stripe.android.core.injection.WeakMapInjectorRegistry -import com.stripe.android.link.account.CookieStore import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.confirmation.ConfirmationManager import com.stripe.android.link.injection.DaggerLinkViewModelFactoryComponent @@ -21,7 +19,6 @@ import com.stripe.android.link.ui.verification.VerificationViewModel import com.stripe.android.link.ui.wallet.WalletViewModel import com.stripe.android.model.PaymentIntent import com.stripe.android.model.StripeIntent -import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -29,8 +26,7 @@ import javax.inject.Inject */ internal class LinkActivityViewModel @Inject internal constructor( args: LinkActivityContract.Args, - private val linkAccountManager: LinkAccountManager, - private val cookieStore: CookieStore, + val linkAccountManager: LinkAccountManager, val navigator: Navigator, private val confirmationManager: ConfirmationManager ) : ViewModel() { @@ -41,40 +37,10 @@ internal class LinkActivityViewModel @Inject internal constructor( */ lateinit var injector: NonFallbackInjector - val startDestination = args.customerEmail?.let { email -> - if (cookieStore.isEmailLoggedOut(email)) null else email - }?.let { - LinkScreen.Loading.route - } ?: LinkScreen.SignUp.route - val linkAccount = linkAccountManager.linkAccount init { assertStripeIntentIsValid(args.stripeIntent) - - if (startDestination == LinkScreen.Loading.route) { - // Loading screen is shown only when customer email is not null - val consumerEmail = requireNotNull(args.customerEmail) - viewModelScope.launch { - navigator.navigateTo( - linkAccountManager.lookupConsumer(consumerEmail).fold( - onSuccess = { - it?.let { linkAccount -> - if (linkAccount.isVerified) { - LinkScreen.Wallet - } else { - LinkScreen.Verification - } - } ?: LinkScreen.SignUp(consumerEmail) - }, - onFailure = { - LinkScreen.SignUp(consumerEmail) - } - ), - clearBackStack = true - ) - } - } } fun setupPaymentLauncher(activityResultCaller: ActivityResultCaller) { @@ -102,7 +68,7 @@ internal class LinkActivityViewModel @Inject internal constructor( } internal class Factory( - private val application: Application, + private val applicationSupplier: () -> Application, private val starterArgsSupplier: () -> LinkActivityContract.Args ) : ViewModelProvider.Factory, Injectable { internal data class FallbackInitializeParam( @@ -140,15 +106,15 @@ internal class LinkActivityViewModel @Inject internal constructor( ) fallbackInitialize( FallbackInitializeParam( - application, + applicationSupplier(), starterArgs, starterArgs.injectionParams?.enableLogging ?: false, starterArgs.injectionParams?.publishableKey - ?: PaymentConfiguration.getInstance(application).publishableKey, + ?: PaymentConfiguration.getInstance(applicationSupplier()).publishableKey, if (starterArgs.injectionParams != null) { starterArgs.injectionParams.stripeAccountId } else { - PaymentConfiguration.getInstance(application).stripeAccountId + PaymentConfiguration.getInstance(applicationSupplier()).stripeAccountId }, starterArgs.injectionParams?.productUsage ?: emptySet() ) diff --git a/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt b/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt index 48fb8e234fa..90abca5be9a 100644 --- a/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt +++ b/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt @@ -12,8 +12,15 @@ import com.stripe.android.core.injection.STRIPE_ACCOUNT_ID import com.stripe.android.core.injection.UIContext import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.core.networking.AnalyticsRequestExecutor +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.injection.CUSTOMER_EMAIL import com.stripe.android.link.injection.DaggerLinkPaymentLauncherComponent +import com.stripe.android.link.injection.LinkPaymentLauncherComponent +import com.stripe.android.link.injection.MERCHANT_NAME +import com.stripe.android.link.injection.NonFallbackInjectable import com.stripe.android.link.injection.NonFallbackInjector +import com.stripe.android.link.model.AccountStatus +import com.stripe.android.link.ui.inline.InlineSignupViewModel import com.stripe.android.link.ui.signup.SignUpViewModel import com.stripe.android.link.ui.verification.VerificationViewModel import com.stripe.android.link.ui.wallet.WalletViewModel @@ -23,6 +30,8 @@ import com.stripe.android.networking.StripeRepository import com.stripe.android.payments.core.injection.PRODUCT_USAGE import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import javax.inject.Named import kotlin.coroutines.CoroutineContext @@ -30,8 +39,9 @@ import kotlin.coroutines.CoroutineContext * Launcher for an Activity that will confirm a payment using Link. */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class LinkPaymentLauncher @AssistedInject constructor( - @Assisted private val activityResultLauncher: ActivityResultLauncher, +class LinkPaymentLauncher @AssistedInject internal constructor( + @Assisted(MERCHANT_NAME) private val merchantName: String, + @Assisted(CUSTOMER_EMAIL) private val customerEmail: String?, context: Context, @Named(PRODUCT_USAGE) private val productUsage: Set, @Named(PUBLISHABLE_KEY) private val publishableKeyProvider: () -> String, @@ -42,8 +52,11 @@ class LinkPaymentLauncher @AssistedInject constructor( paymentAnalyticsRequestFactory: PaymentAnalyticsRequestFactory, analyticsRequestExecutor: AnalyticsRequestExecutor, stripeRepository: StripeRepository -) { +) : NonFallbackInjectable { + private var args: LinkActivityContract.Args? = null private val launcherComponentBuilder = DaggerLinkPaymentLauncherComponent.builder() + .merchantName(merchantName) + .customerEmail(customerEmail) .context(context) .ioContext(ioContext) .uiContext(uiContext) @@ -60,11 +73,45 @@ class LinkPaymentLauncher @AssistedInject constructor( requireNotNull(LinkPaymentLauncher::class.simpleName) ) + /** + * The dependency injector for all injectable classes in Link. + * This is safe to hold here because [LinkPaymentLauncher] lives only for as long as + * PaymentSheet's ViewModel is alive. + */ + internal var injector: NonFallbackInjector? = null + + /** + * The [LinkAccountManager], exposed here so that classes that are not injected (like LinkButton + * or LinkInlineSignup) can access it and share the account status with all other components. + */ + internal lateinit var linkAccountManager: LinkAccountManager + + /** + * Publicly visible account status, used by PaymentSheet to display the correct UI. + */ + lateinit var accountStatus: Flow + + /** + * Sets up Link to process the given [StripeIntent]. + * + * This will fetch the user's account if they're already logged in, or lookup the email passed + * in during instantiation. + */ + suspend fun setup(stripeIntent: StripeIntent): AccountStatus { + val component = setupDependencies(stripeIntent) + accountStatus = component.linkAccountManager.accountStatus + linkAccountManager = component.linkAccountManager + return accountStatus.first() + } + fun present( - stripeIntent: StripeIntent, - merchantName: String, - customerEmail: String? = null + activityResultLauncher: ActivityResultLauncher ) { + requireNotNull(args) { "Must call setup before presenting" } + activityResultLauncher.launch(args) + } + + private fun setupDependencies(stripeIntent: StripeIntent): LinkPaymentLauncherComponent { val args = LinkActivityContract.Args( stripeIntent, merchantName, @@ -78,22 +125,18 @@ class LinkPaymentLauncher @AssistedInject constructor( ) ) - setupInjector(args) - activityResultLauncher.launch(args) - } - - private fun setupInjector(args: LinkActivityContract.Args) { - val launcherComponent = launcherComponentBuilder + val component = launcherComponentBuilder .starterArgs(args) .build() val injector = object : NonFallbackInjector { override fun inject(injectable: Injectable<*>) { when (injectable) { - is LinkActivityViewModel.Factory -> launcherComponent.inject(injectable) - is SignUpViewModel.Factory -> launcherComponent.inject(injectable) - is VerificationViewModel.Factory -> launcherComponent.inject(injectable) - is WalletViewModel.Factory -> launcherComponent.inject(injectable) + is LinkActivityViewModel.Factory -> component.inject(injectable) + is SignUpViewModel.Factory -> component.inject(injectable) + is VerificationViewModel.Factory -> component.inject(injectable) + is WalletViewModel.Factory -> component.inject(injectable) + is InlineSignupViewModel.Factory -> component.inject(injectable) else -> { throw IllegalArgumentException("invalid Injectable $injectable requested in $this") } @@ -102,5 +145,8 @@ class LinkPaymentLauncher @AssistedInject constructor( } WeakMapInjectorRegistry.register(injector, injectorKey) + this.args = args + this.injector = injector + return component } } diff --git a/link/src/main/java/com/stripe/android/link/LinkScreen.kt b/link/src/main/java/com/stripe/android/link/LinkScreen.kt index f5bedaa8e3d..09f9b616587 100644 --- a/link/src/main/java/com/stripe/android/link/LinkScreen.kt +++ b/link/src/main/java/com/stripe/android/link/LinkScreen.kt @@ -15,7 +15,7 @@ internal sealed class LinkScreen( object PaymentMethod : LinkScreen("PaymentMethod") class SignUp(email: String? = null) : - LinkScreen("SignUp?${email?.let { "$it=${it.urlEncode()}" }}") { + LinkScreen("SignUp${email?.let { "?$emailArg=${it.urlEncode()}" } ?: ""}") { override val route = super.route companion object { diff --git a/link/src/main/java/com/stripe/android/link/account/CookieStore.kt b/link/src/main/java/com/stripe/android/link/account/CookieStore.kt index 5c3c1f5d36b..04d99cdbd79 100644 --- a/link/src/main/java/com/stripe/android/link/account/CookieStore.kt +++ b/link/src/main/java/com/stripe/android/link/account/CookieStore.kt @@ -52,7 +52,7 @@ internal class CookieStore @Inject constructor( fun isEmailLoggedOut(email: String) = store.read(LOGGED_OUT_EMAIL_HASH) == email.sha256() - private fun storeLoggedOutEmail(email: String) = + fun storeLoggedOutEmail(email: String) = store.write(LOGGED_OUT_EMAIL_HASH, email.sha256()) companion object { diff --git a/link/src/main/java/com/stripe/android/link/account/LinkAccountManager.kt b/link/src/main/java/com/stripe/android/link/account/LinkAccountManager.kt index 181e310bd49..bf351a72b94 100644 --- a/link/src/main/java/com/stripe/android/link/account/LinkAccountManager.kt +++ b/link/src/main/java/com/stripe/android/link/account/LinkAccountManager.kt @@ -1,10 +1,13 @@ package com.stripe.android.link.account +import com.stripe.android.link.LinkActivityContract +import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.repositories.LinkRepository import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -14,17 +17,47 @@ import javax.inject.Singleton */ @Singleton internal class LinkAccountManager @Inject constructor( + args: LinkActivityContract.Args, private val linkRepository: LinkRepository, private val cookieStore: CookieStore ) { - private val _linkAccount = - MutableStateFlow(null) + private val _linkAccount = MutableStateFlow(null) var linkAccount: StateFlow = _linkAccount + val accountStatus = + linkAccount.transform { value -> + emit( + // If we already fetched an account, return its status + value?.accountStatus + ?: ( + // If consumer has previously logged in, fetch their account + cookieStore.getAuthSessionCookie()?.let { + lookupConsumer(null).getOrNull()?.accountStatus + } + // If a customer email was passed in, lookup the account, + // unless the user has logged out of this account + ?: args.customerEmail?.let { + if (hasUserLoggedOut(it)) { + AccountStatus.SignedOut + } else { + lookupConsumer(args.customerEmail).getOrNull()?.accountStatus + } + } ?: AccountStatus.SignedOut + ) + ) + } + + /** + * Keeps track of whether the user has logged out during this session. If that's the case, we + * want to ignore the email passed in by the merchant to avoid confusion. + */ + private var userHasLoggedOut = false + /** * Retrieves the Link account associated with the email and starts verification, if needed. + * When the [email] parameter is null, will lookup the account for the currently stored cookie. */ - suspend fun lookupConsumer(email: String): Result = + suspend fun lookupConsumer(email: String?): Result = linkRepository.lookupConsumer(email, cookie()) .map { consumerSessionLookup -> setAndReturnNullable( @@ -109,22 +142,36 @@ internal class LinkAccountManager @Inject constructor( val cookie = cookie() cookieStore.logout(account.email) _linkAccount.value = null + userHasLoggedOut = true GlobalScope.launch { linkRepository.logout(account.clientSecret, cookie) } } + /** + * Whether the user has logged out from any account during this session, or the last logout was + * from the [email] passed as parameter, even if in a previous session. + */ + fun hasUserLoggedOut(email: String?) = userHasLoggedOut || email?.let { + cookieStore.isEmailLoggedOut(it) + } ?: false + private fun setAndReturn(linkAccount: LinkAccount): LinkAccount { _linkAccount.value = linkAccount cookieStore.updateAuthSessionCookie(linkAccount.getAuthSessionCookie()) + if (cookieStore.isEmailLoggedOut(linkAccount.email)) { + cookieStore.storeLoggedOutEmail("") + } return linkAccount } - private fun setAndReturnNullable(linkAccount: LinkAccount?): LinkAccount? { - _linkAccount.value = linkAccount - cookieStore.updateAuthSessionCookie(linkAccount?.getAuthSessionCookie()) - return linkAccount - } + private fun setAndReturnNullable(linkAccount: LinkAccount?): LinkAccount? = + linkAccount?.let { + setAndReturn(it) + } ?: run { + _linkAccount.value = null + null + } private fun cookie() = cookieStore.getAuthSessionCookie() } diff --git a/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherComponent.kt b/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherComponent.kt index 9c6cae2ef77..bde2df841a6 100644 --- a/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherComponent.kt +++ b/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherComponent.kt @@ -10,6 +10,8 @@ import com.stripe.android.core.injection.UIContext import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.link.LinkActivityContract import com.stripe.android.link.LinkActivityViewModel +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.ui.inline.InlineSignupViewModel import com.stripe.android.link.ui.signup.SignUpViewModel import com.stripe.android.link.ui.verification.VerificationViewModel import com.stripe.android.link.ui.wallet.WalletViewModel @@ -34,13 +36,22 @@ import kotlin.coroutines.CoroutineContext ] ) internal abstract class LinkPaymentLauncherComponent { + abstract val linkAccountManager: LinkAccountManager + abstract fun inject(factory: LinkActivityViewModel.Factory) abstract fun inject(factory: SignUpViewModel.Factory) abstract fun inject(factory: VerificationViewModel.Factory) abstract fun inject(factory: WalletViewModel.Factory) + abstract fun inject(factory: InlineSignupViewModel.Factory) @Component.Builder interface Builder { + @BindsInstance + fun merchantName(@Named(MERCHANT_NAME) merchantName: String): Builder + + @BindsInstance + fun customerEmail(@Named(CUSTOMER_EMAIL) customerEmail: String?): Builder + @BindsInstance fun context(context: Context): Builder diff --git a/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherFactory.kt b/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherFactory.kt index cea5e17cb9a..a80c2c1e915 100644 --- a/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherFactory.kt +++ b/link/src/main/java/com/stripe/android/link/injection/LinkPaymentLauncherFactory.kt @@ -1,15 +1,15 @@ package com.stripe.android.link.injection -import androidx.activity.result.ActivityResultLauncher import androidx.annotation.RestrictTo -import com.stripe.android.link.LinkActivityContract import com.stripe.android.link.LinkPaymentLauncher +import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @AssistedFactory @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) interface LinkPaymentLauncherFactory { fun create( - activityResultLauncher: ActivityResultLauncher + @Assisted(MERCHANT_NAME) merchantName: String, + @Assisted(CUSTOMER_EMAIL) customerEmail: String? ): LinkPaymentLauncher } diff --git a/link/src/main/java/com/stripe/android/link/injection/NamedConstants.kt b/link/src/main/java/com/stripe/android/link/injection/NamedConstants.kt new file mode 100644 index 00000000000..e962d7db9e9 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/injection/NamedConstants.kt @@ -0,0 +1,15 @@ +package com.stripe.android.link.injection + +import androidx.annotation.RestrictTo + +/** + * Identifies the customer-facing business name. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +const val MERCHANT_NAME = "merchantName" + +/** + * Identifies the email of the customer using the app, used to pre-fill the form. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +const val CUSTOMER_EMAIL = "customerEmail" diff --git a/link/src/main/java/com/stripe/android/link/model/AccountStatus.kt b/link/src/main/java/com/stripe/android/link/model/AccountStatus.kt new file mode 100644 index 00000000000..ead777b977c --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/model/AccountStatus.kt @@ -0,0 +1,8 @@ +package com.stripe.android.link.model + +enum class AccountStatus { + Verified, + VerificationStarted, + NeedsVerification, + SignedOut +} diff --git a/link/src/main/java/com/stripe/android/link/model/LinkAccount.kt b/link/src/main/java/com/stripe/android/link/model/LinkAccount.kt index c2a1af5b557..f8e511930af 100644 --- a/link/src/main/java/com/stripe/android/link/model/LinkAccount.kt +++ b/link/src/main/java/com/stripe/android/link/model/LinkAccount.kt @@ -16,8 +16,25 @@ internal class LinkAccount(private val consumerSession: ConsumerSession) { val isVerified: Boolean = consumerSession.containsVerifiedSMSSession() || consumerSession.isVerifiedForSignup() + val accountStatus = when { + isVerified -> { + AccountStatus.Verified + } + consumerSession.containsSMSSessionStarted() -> { + AccountStatus.VerificationStarted + } + else -> { + AccountStatus.NeedsVerification + } + } + fun getAuthSessionCookie() = consumerSession.authSessionClientSecret + private fun ConsumerSession.containsSMSSessionStarted() = verificationSessions.find { + it.type == ConsumerSession.VerificationSession.SessionType.Sms && + it.state == ConsumerSession.VerificationSession.SessionState.Started + } != null + private fun ConsumerSession.containsVerifiedSMSSession() = verificationSessions.find { it.type == ConsumerSession.VerificationSession.SessionType.Sms && it.state == ConsumerSession.VerificationSession.SessionState.Verified diff --git a/link/src/main/java/com/stripe/android/link/model/Navigator.kt b/link/src/main/java/com/stripe/android/link/model/Navigator.kt index da2581f0adb..89a7140ad80 100644 --- a/link/src/main/java/com/stripe/android/link/model/Navigator.kt +++ b/link/src/main/java/com/stripe/android/link/model/Navigator.kt @@ -23,7 +23,9 @@ internal class Navigator @Inject constructor() { ) = navigationController?.let { navController -> navController.navigate(target.route) { if (clearBackStack) { - popUpTo(navController.backQueue.first().destination.id) + popUpTo(navController.backQueue.first().destination.id) { + inclusive = true + } } } } diff --git a/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt b/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt index bdaf8e1ba68..adcb6241288 100644 --- a/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt +++ b/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt @@ -30,7 +30,7 @@ internal class LinkApiRepository @Inject constructor( ) : LinkRepository { override suspend fun lookupConsumer( - email: String, + email: String?, authSessionCookie: String? ): Result = withContext(workContext) { runCatching { diff --git a/link/src/main/java/com/stripe/android/link/repositories/LinkRepository.kt b/link/src/main/java/com/stripe/android/link/repositories/LinkRepository.kt index 0f47d36bbe0..73d735a2dcb 100644 --- a/link/src/main/java/com/stripe/android/link/repositories/LinkRepository.kt +++ b/link/src/main/java/com/stripe/android/link/repositories/LinkRepository.kt @@ -13,7 +13,7 @@ internal interface LinkRepository { * Check if the email already has a link account. */ suspend fun lookupConsumer( - email: String, + email: String?, authSessionCookie: String? ): Result diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkButton.kt b/link/src/main/java/com/stripe/android/link/ui/LinkButton.kt index 311834b3343..1da88a14eef 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkButton.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkButton.kt @@ -3,6 +3,10 @@ package com.stripe.android.link.ui import android.content.Context import android.util.AttributeSet import androidx.annotation.RestrictTo +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Button @@ -11,33 +15,58 @@ import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.LocalContentAlpha import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.stripe.android.link.LinkPaymentLauncher import com.stripe.android.link.R import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkColors +private val LinkButtonVerticalPadding = 6.dp +private val LinkButtonHorizontalPadding = 10.dp + @Preview @Composable private fun LinkButton() { LinkButton( enabled = true, + email = "example@stripe.com", onClick = {} ) } +@Composable +private fun LinkButton( + linkPaymentLauncher: LinkPaymentLauncher, + enabled: Boolean, + onClick: () -> Unit +) { + val account = linkPaymentLauncher.linkAccountManager.linkAccount.collectAsState() + + LinkButton( + enabled = enabled, + email = account.value?.email, + onClick = onClick + ) +} + @Composable private fun LinkButton( enabled: Boolean, + email: String?, onClick: () -> Unit ) { CompositionLocalProvider( @@ -50,6 +79,12 @@ private fun LinkButton( colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues( + start = LinkButtonHorizontalPadding, + top = LinkButtonVerticalPadding, + end = LinkButtonHorizontalPadding, + bottom = LinkButtonVerticalPadding ) ) { Icon( @@ -65,6 +100,24 @@ private fun LinkButton( tint = MaterialTheme.linkColors.buttonLabel .copy(alpha = LocalContentAlpha.current) ) + email?.let { + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.05f), + shape = MaterialTheme.shapes.small + ) + ) { + Text( + text = it, + modifier = Modifier + .padding(6.dp), + color = MaterialTheme.linkColors.buttonLabel, + fontSize = 14.sp, + ) + } + } } } } @@ -76,7 +129,7 @@ private fun LinkButton( * Set the `onClick` function to launch LinkPaymentLauncher. */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class LinkViewButton @JvmOverloads constructor( +class LinkButtonView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0 @@ -85,6 +138,7 @@ class LinkViewButton @JvmOverloads constructor( override var shouldCreateCompositionOnAttachedToWindow: Boolean = false private set + var linkPaymentLauncher: LinkPaymentLauncher? = null var onClick by mutableStateOf({}) private var isEnabledState by mutableStateOf(isEnabled) @@ -95,9 +149,12 @@ class LinkViewButton @JvmOverloads constructor( @Composable override fun Content() { - LinkButton( - isEnabledState, - onClick - ) + linkPaymentLauncher?.let { + LinkButton( + it, + isEnabledState, + onClick + ) + } } } diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkTerms.kt b/link/src/main/java/com/stripe/android/link/ui/LinkTerms.kt new file mode 100644 index 00000000000..cba74bc3853 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/LinkTerms.kt @@ -0,0 +1,24 @@ +package com.stripe.android.link.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.stripe.android.link.R + +@Preview +@Composable +fun LinkTerms( + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Center +) { + Text( + text = stringResource(R.string.sign_up_terms), + modifier = modifier, + textAlign = textAlign, + style = MaterialTheme.typography.caption + ) +} diff --git a/link/src/main/java/com/stripe/android/link/ui/inline/InlineSignupViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/inline/InlineSignupViewModel.kt new file mode 100644 index 00000000000..5e3ab897fe9 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/inline/InlineSignupViewModel.kt @@ -0,0 +1,122 @@ +package com.stripe.android.link.ui.inline + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.stripe.android.core.Logger +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.injection.CUSTOMER_EMAIL +import com.stripe.android.link.injection.MERCHANT_NAME +import com.stripe.android.link.injection.NonFallbackInjectable +import com.stripe.android.link.injection.NonFallbackInjector +import com.stripe.android.link.ui.signup.SignUpState +import com.stripe.android.link.ui.signup.SignUpViewModel +import com.stripe.android.ui.core.elements.EmailSpec +import com.stripe.android.ui.core.elements.IdentifierSpec +import com.stripe.android.ui.core.elements.SectionFieldElement +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named + +internal class InlineSignupViewModel @Inject constructor( + @Named(MERCHANT_NAME) val merchantName: String, + @Named(CUSTOMER_EMAIL) customerEmail: String?, + private val linkAccountManager: LinkAccountManager, + private val logger: Logger +) : ViewModel() { + private val prefilledEmail = + if (linkAccountManager.hasUserLoggedOut(customerEmail)) null else customerEmail + + val emailElement: SectionFieldElement = + EmailSpec.transform(mapOf(IdentifierSpec.Email to prefilledEmail)) + + /** + * Emits the email entered in the form if valid, null otherwise. + */ + private val consumerEmail: StateFlow = + emailElement.getFormFieldValueFlow().map { formFieldsList -> + // formFieldsList contains only one element, for the email. Take the second value of + // the pair, which is the FormFieldEntry containing the value entered by the user. + formFieldsList.firstOrNull()?.second?.takeIf { it.isComplete }?.value + }.stateIn(viewModelScope, SharingStarted.Lazily, prefilledEmail) + + private val _signUpStatus = MutableStateFlow(SignUpState.InputtingEmail) + val signUpState: StateFlow = _signUpStatus + + val isExpanded = MutableStateFlow(false) + + /** + * Whether we have enough information to proceed with the payment flow. + * This will be true when the user has entered an email that already has a link account and just + * needs verification, or when they entered a new email and phone number. + */ + val isReady = MutableStateFlow(true) + private var hasExpanded = false + + private var debouncer = SignUpViewModel.Debouncer(prefilledEmail) + + fun toggleExpanded() { + isExpanded.value = !isExpanded.value + // First time user checks the box, start listening to email input + if (isExpanded.value && !hasExpanded) { + hasExpanded = true + debouncer.startWatching( + coroutineScope = viewModelScope, + emailFlow = consumerEmail, + onStateChanged = { + _signUpStatus.value = it + if (it == SignUpState.InputtingEmail || it == SignUpState.InputtingPhone) { + isReady.value = false + } + }, + onValidEmailEntered = { + viewModelScope.launch { + lookupConsumerEmail(it) + } + } + ) + } + } + + fun onPhoneInputCompleted(phoneNumber: String) { + isReady.value = true + } + + private suspend fun lookupConsumerEmail(email: String) { + linkAccountManager.lookupConsumer(email).fold( + onSuccess = { + if (it != null) { + isReady.value = true + _signUpStatus.value = SignUpState.InputtingEmail + } else { + _signUpStatus.value = SignUpState.InputtingPhone + } + }, + onFailure = ::onError + ) + } + + private fun onError(error: Throwable) { + logger.error(error.localizedMessage ?: "Internal error.") + // TODO(brnunes-stripe): Add localized error messages, show them in UI. + } + + internal class Factory( + private val injector: NonFallbackInjector + ) : ViewModelProvider.Factory, NonFallbackInjectable { + + @Inject + lateinit var viewModel: InlineSignupViewModel + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + injector.inject(this) + return viewModel as T + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt b/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt new file mode 100644 index 00000000000..b8c356c93c2 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt @@ -0,0 +1,210 @@ +package com.stripe.android.link.ui.inline + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.RestrictTo +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.stripe.android.link.LinkPaymentLauncher +import com.stripe.android.link.R +import com.stripe.android.link.injection.NonFallbackInjector +import com.stripe.android.link.theme.DefaultLinkTheme +import com.stripe.android.link.ui.LinkTerms +import com.stripe.android.link.ui.signup.EmailCollectionSection +import com.stripe.android.link.ui.signup.PhoneCollectionSection +import com.stripe.android.link.ui.signup.SignUpState +import com.stripe.android.ui.core.PaymentsTheme +import com.stripe.android.ui.core.elements.EmailSpec +import com.stripe.android.ui.core.elements.IdentifierSpec +import com.stripe.android.ui.core.elements.SectionFieldElement +import com.stripe.android.ui.core.elements.menu.Checkbox +import kotlinx.coroutines.flow.MutableStateFlow + +@Preview +@Composable +private fun Preview() { + LinkInlineSignup( + merchantName = "Example, Inc.", + emailElement = EmailSpec.transform(mapOf(IdentifierSpec.Email to "email")), + signUpState = SignUpState.InputtingEmail, + isExpanded = true, + toggleExpanded = {}, + onPhoneInputCompleted = {}, + onUserInteracted = {} + ) +} + +@Composable +private fun LinkInlineSignup( + injector: NonFallbackInjector, + onUserInteracted: () -> Unit, + onReady: (Boolean) -> Unit +) { + val viewModel: InlineSignupViewModel = viewModel( + factory = InlineSignupViewModel.Factory(injector) + ) + + val signUpState by viewModel.signUpState.collectAsState(SignUpState.InputtingEmail) + val isExpanded by viewModel.isExpanded.collectAsState(false) + + LinkInlineSignup( + merchantName = viewModel.merchantName, + emailElement = viewModel.emailElement, + signUpState = signUpState, + isExpanded = isExpanded, + toggleExpanded = viewModel::toggleExpanded, + onPhoneInputCompleted = viewModel::onPhoneInputCompleted, + onUserInteracted = onUserInteracted + ) +} + +@Composable +private fun LinkInlineSignup( + merchantName: String, + emailElement: SectionFieldElement, + signUpState: SignUpState, + isExpanded: Boolean, + toggleExpanded: () -> Unit, + onPhoneInputCompleted: (String) -> Unit, + onUserInteracted: () -> Unit +) { + var phoneNumber by remember { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + + DefaultLinkTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = PaymentsTheme.colors.colorComponentBorder, + shape = PaymentsTheme.shapes.material.medium + ) + .background( + color = PaymentsTheme.colors.component, + shape = PaymentsTheme.shapes.material.medium + ) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .clickable { + toggleExpanded() + onUserInteracted() + } + ) { + Checkbox( + checked = isExpanded, + onCheckedChange = null, // needs to be null for accessibility on row click to work + modifier = Modifier.padding(end = 8.dp), + enabled = true + ) + Column { + Text( + text = stringResource(id = R.string.inline_sign_up_header), + style = PaymentsTheme.typography.body1.copy(fontWeight = FontWeight.Bold), + color = PaymentsTheme.colors.material.onSurface + ) + Text( + text = stringResource(R.string.sign_up_message, merchantName), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + style = PaymentsTheme.typography.body1, + color = PaymentsTheme.colors.material.onSurface + ) + } + } + AnimatedVisibility( + visible = isExpanded, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + EmailCollectionSection(emailElement = emailElement, signUpState = signUpState) + + AnimatedVisibility( + visible = signUpState == SignUpState.InputtingPhone + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // TODO(brnunes-stripe): Migrate to phone number collection element + PhoneCollectionSection( + phoneNumber = phoneNumber, + onPhoneNumberChanged = { + phoneNumber = it + if (phoneNumber.length == 10) { + onPhoneInputCompleted(phoneNumber) + keyboardController?.hide() + } + } + ) + LinkTerms( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 24.dp), + textAlign = TextAlign.Left + ) + } + } + } + } + } + } +} + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class LinkInlineSignupView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : AbstractComposeView(context, attrs, defStyle) { + + var linkLauncher: LinkPaymentLauncher? = null + + /** + * Keep track of whether the user has interacted with the inline signup UI, so that it's not + * hidden when the current Link account changes. + */ + var hasUserInteracted = false + + /** + * Whether enough information has been collected to proceed with the payment flow. + * This will be true when the user has entered an email that already has a link account and just + * needs verification, or when they entered a new email and phone number. + */ + val isReady = MutableStateFlow(true) + + @Composable + override fun Content() { + linkLauncher?.injector?.let { + PaymentsTheme { + LinkInlineSignup( + injector = it, + onUserInteracted = { hasUserInteracted = true }, + onReady = { isReady.value = it } + ) + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt index 571b13b9a40..999f77916c3 100644 --- a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt @@ -34,6 +34,7 @@ import com.stripe.android.link.R import com.stripe.android.link.injection.NonFallbackInjector import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkTextFieldColors +import com.stripe.android.link.ui.LinkTerms import com.stripe.android.link.ui.PrimaryButton import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.ui.core.elements.EmailSpec @@ -94,6 +95,9 @@ internal fun SignUpBody( LocalFocusManager.current.clearFocus() } + var phoneNumber by remember { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + Column( modifier = Modifier .fillMaxWidth() @@ -124,13 +128,38 @@ internal fun SignUpBody( AnimatedVisibility( visible = signUpState == SignUpState.InputtingPhone ) { - PhoneCollectionSection(onSignUpClick) + Column(modifier = Modifier.fillMaxWidth()) { + // TODO(brnunes-stripe): Migrate to phone number collection element + PhoneCollectionSection( + phoneNumber = phoneNumber, + onPhoneNumberChanged = { + phoneNumber = it + } + ) + LinkTerms( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 24.dp), + textAlign = TextAlign.Center + ) + PrimaryButton( + label = stringResource(R.string.sign_up), + state = if (phoneNumber.length == 10) { + PrimaryButtonState.Enabled + } else { + PrimaryButtonState.Disabled + } + ) { + onSignUpClick(phoneNumber) + keyboardController?.hide() + } + } } } } @Composable -private fun EmailCollectionSection( +internal fun EmailCollectionSection( emailElement: SectionFieldElement, signUpState: SignUpState ) { @@ -173,13 +202,10 @@ private fun EmailCollectionSection( } @Composable -private fun PhoneCollectionSection( - onSignUpClick: (String) -> Unit +internal fun PhoneCollectionSection( + phoneNumber: String, + onPhoneNumberChanged: (String) -> Unit ) { - // TODO(brnunes-stripe): Migrate to phone number collection element - var phone by remember { mutableStateOf("") } - val keyboardController = LocalSoftwareKeyboardController.current - Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -188,10 +214,8 @@ private fun PhoneCollectionSection( TextField( modifier = Modifier .fillMaxWidth(), - value = phone, - onValueChange = { - phone = it - }, + value = phoneNumber, + onValueChange = onPhoneNumberChanged, label = { Text(text = "Mobile Number") }, @@ -204,24 +228,5 @@ private fun PhoneCollectionSection( singleLine = true ) } - Text( - text = stringResource(R.string.sign_up_terms), - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 24.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.caption - ) - PrimaryButton( - label = stringResource(R.string.sign_up), - state = if (phone.length == 10) { - PrimaryButtonState.Enabled - } else { - PrimaryButtonState.Disabled - } - ) { - onSignUpClick(phone) - keyboardController?.hide() - } } } diff --git a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpViewModel.kt index 21ec6957aa4..af5f6dc8bf0 100644 --- a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpViewModel.kt @@ -15,6 +15,7 @@ import com.stripe.android.link.model.Navigator import com.stripe.android.ui.core.elements.EmailSpec import com.stripe.android.ui.core.elements.IdentifierSpec import com.stripe.android.ui.core.elements.SectionFieldElement +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -33,13 +34,15 @@ import javax.inject.Provider */ internal class SignUpViewModel @Inject constructor( args: LinkActivityContract.Args, - @Named(PREFILLED_EMAIL) private val prefilledEmail: String?, + @Named(PREFILLED_EMAIL) private val customerEmail: String?, private val linkAccountManager: LinkAccountManager, private val navigator: Navigator, private val logger: Logger ) : ViewModel() { - val merchantName: String = args.merchantName + private val prefilledEmail = + if (linkAccountManager.hasUserLoggedOut(customerEmail)) null else customerEmail + val merchantName: String = args.merchantName val emailElement: SectionFieldElement = EmailSpec.transform( mapOf( IdentifierSpec.Email to prefilledEmail @@ -59,40 +62,21 @@ internal class SignUpViewModel @Inject constructor( private val _signUpStatus = MutableStateFlow(SignUpState.InputtingEmail) val signUpState: StateFlow = _signUpStatus - /** - * Holds a Job that looks up the email after a delay, so that we can cancel it if the user - * continues typing. - */ - private var lookupJob: Job? = null + private val debouncer = Debouncer(prefilledEmail) init { - viewModelScope.launch { - consumerEmail.collect { email -> - // The first emitted value is the one provided in the constructor arguments, and - // shouldn't trigger a lookup. - if (email == prefilledEmail && lookupJob == null) { - // If it's a valid email, collect phone number - if (email != null) { - _signUpStatus.value = SignUpState.InputtingPhone - } - return@collect - } - - lookupJob?.cancel() - - if (email != null) { - lookupJob = launch { - delay(LOOKUP_DEBOUNCE_MS) - if (isActive) { - _signUpStatus.value = SignUpState.VerifyingEmail - lookupConsumerEmail(email) - } - } - } else { - _signUpStatus.value = SignUpState.InputtingEmail + debouncer.startWatching( + coroutineScope = viewModelScope, + emailFlow = consumerEmail, + onStateChanged = { + _signUpStatus.value = it + }, + onValidEmailEntered = { + viewModelScope.launch { + lookupConsumerEmail(it) } } - } + ) } fun onSignUpClick(phone: String) { @@ -138,6 +122,51 @@ internal class SignUpViewModel @Inject constructor( // TODO(brnunes-stripe): Add localized error messages, show them in UI. } + internal class Debouncer( + private val initialEmail: String? + ) { + /** + * Holds a Job that looks up the email after a delay, so that we can cancel it if the user + * continues typing. + */ + private var lookupJob: Job? = null + + fun startWatching( + coroutineScope: CoroutineScope, + emailFlow: StateFlow, + onStateChanged: (SignUpState) -> Unit, + onValidEmailEntered: (String) -> Unit + ) { + coroutineScope.launch { + emailFlow.collect { email -> + // The first emitted value is the one provided in the constructor arguments, and + // shouldn't trigger a lookup. + if (email == initialEmail && lookupJob == null) { + // If it's a valid email, collect phone number + if (email != null) { + onStateChanged(SignUpState.InputtingPhone) + } + return@collect + } + + lookupJob?.cancel() + + if (email != null) { + lookupJob = launch { + delay(LOOKUP_DEBOUNCE_MS) + if (isActive) { + onStateChanged(SignUpState.VerifyingEmail) + onValidEmailEntered(email) + } + } + } else { + onStateChanged(SignUpState.InputtingEmail) + } + } + } + } + } + internal class Factory( private val injector: NonFallbackInjector, private val email: String? diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt new file mode 100644 index 00000000000..4a955e9848c --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt @@ -0,0 +1,73 @@ +package com.stripe.android.link.ui.verification + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.stripe.android.link.LinkPaymentLauncher +import com.stripe.android.link.R +import com.stripe.android.link.theme.DefaultLinkTheme +import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.ui.LinkAppBar + +@Composable +fun LinkVerificationDialog( + linkLauncher: LinkPaymentLauncher, + onDialogDismissed: () -> Unit, + onVerificationCompleted: () -> Unit +) { + val injector = requireNotNull(linkLauncher.injector) + val openDialog = remember { mutableStateOf(true) } + val linkAccount = linkLauncher.linkAccountManager.linkAccount.collectAsState() + + linkAccount.value?.let { account -> + if (openDialog.value) { + Dialog( + onDismissRequest = { + openDialog.value = false + onDialogDismissed() + } + ) { + DefaultLinkTheme { + Surface( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.linkColors.disabledText, + shape = MaterialTheme.shapes.medium + ), + shape = MaterialTheme.shapes.medium + ) { + Column(Modifier.padding(horizontal = 20.dp)) { + LinkAppBar( + email = account.email, + onCloseButtonClick = { + openDialog.value = false + onDialogDismissed() + } + ) + VerificationBody( + headerStringResId = R.string.verification_header_prefilled, + messageStringResId = R.string.verification_message, + showChangeEmailMessage = false, + linkAccount = account, + injector = injector, + onVerificationCompleted = onVerificationCompleted + ) + } + } + } + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt index f81fc0e3993..521bff37a61 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt @@ -1,18 +1,18 @@ package com.stripe.android.link.ui.verification import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ButtonDefaults import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -43,6 +43,9 @@ import com.stripe.android.ui.core.elements.SectionCard private fun VerificationBodyPreview() { DefaultLinkTheme { VerificationBody( + headerStringResId = R.string.verification_header, + messageStringResId = R.string.verification_message, + showChangeEmailMessage = true, redactedPhoneNumber = "+1********23", email = "test@stripe.com", onCodeEntered = { }, @@ -54,9 +57,27 @@ private fun VerificationBodyPreview() { } @Composable -internal fun VerificationBody( +internal fun VerificationBodyFullFlow( linkAccount: LinkAccount, injector: NonFallbackInjector +) { + VerificationBody( + headerStringResId = R.string.verification_header, + messageStringResId = R.string.verification_message, + showChangeEmailMessage = true, + linkAccount = linkAccount, + injector = injector + ) +} + +@Composable +internal fun VerificationBody( + @StringRes headerStringResId: Int, + @StringRes messageStringResId: Int, + showChangeEmailMessage: Boolean, + linkAccount: LinkAccount, + injector: NonFallbackInjector, + onVerificationCompleted: (() -> Unit)? = null ) { val viewModel: VerificationViewModel = viewModel( factory = VerificationViewModel.Factory( @@ -65,18 +86,28 @@ internal fun VerificationBody( ) ) + onVerificationCompleted?.let { + viewModel.onVerificationCompleted = it + } + VerificationBody( + headerStringResId = headerStringResId, + messageStringResId = messageStringResId, + showChangeEmailMessage = showChangeEmailMessage, redactedPhoneNumber = viewModel.linkAccount.redactedPhoneNumber, email = viewModel.linkAccount.email, onCodeEntered = viewModel::onVerificationCodeEntered, onBack = viewModel::onBack, onChangeEmailClick = viewModel::onChangeEmailClicked, - onResendCodeClick = viewModel::onResendCodeClicked + onResendCodeClick = viewModel::startVerification ) } @Composable internal fun VerificationBody( + @StringRes headerStringResId: Int, + @StringRes messageStringResId: Int, + showChangeEmailMessage: Boolean, redactedPhoneNumber: String, email: String, onCodeEntered: (String) -> Unit, @@ -92,7 +123,7 @@ internal fun VerificationBody( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(R.string.verification_header), + text = stringResource(headerStringResId), modifier = Modifier .padding(vertical = 4.dp), textAlign = TextAlign.Center, @@ -100,7 +131,7 @@ internal fun VerificationBody( color = MaterialTheme.colors.onPrimary ) Text( - text = stringResource(R.string.verification_message, redactedPhoneNumber), + text = stringResource(messageStringResId, redactedPhoneNumber), modifier = Modifier .fillMaxWidth() .padding(top = 4.dp, bottom = 30.dp), @@ -109,41 +140,41 @@ internal fun VerificationBody( color = MaterialTheme.colors.onSecondary ) VerificationCodeInput(onCodeEntered) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 22.dp, bottom = 30.dp), - horizontalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(id = R.string.verification_not_email, email), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSecondary - ) - Text( - text = stringResource(id = R.string.verification_change_email), + if (showChangeEmailMessage) { + Row( modifier = Modifier - .padding(start = 4.dp) - .clickable(onClick = onChangeEmailClick), - style = MaterialTheme.typography.body2 - .merge(TextStyle(textDecoration = TextDecoration.Underline)), - color = MaterialTheme.colors.onSecondary - ) + .fillMaxWidth() + .padding(top = 2.dp, bottom = 30.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.verification_not_email, email), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSecondary + ) + Text( + text = stringResource(id = R.string.verification_change_email), + modifier = Modifier + .padding(start = 4.dp) + .clickable(onClick = onChangeEmailClick), + style = MaterialTheme.typography.body2 + .merge(TextStyle(textDecoration = TextDecoration.Underline)), + color = MaterialTheme.colors.onSecondary + ) + } } - TextButton( - onClick = onResendCodeClick, + Box( modifier = Modifier .border( width = 1.dp, color = MaterialTheme.linkColors.disabledText, - shape = MaterialTheme.shapes.medium - ), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.background - ) + shape = MaterialTheme.shapes.small + ) + .clickable(onClick = onResendCodeClick), ) { Text( text = stringResource(id = R.string.verification_resend), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.button, color = MaterialTheme.colors.onPrimary ) @@ -159,7 +190,9 @@ private fun VerificationCodeInput( var code by remember { mutableStateOf("") } Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { SectionCard { diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt index 46fa584d29c..42f6e7ba2a5 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationViewModel.kt @@ -9,6 +9,7 @@ import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.injection.NonFallbackInjectable import com.stripe.android.link.injection.NonFallbackInjector import com.stripe.android.link.injection.SignedInViewModelSubcomponent +import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.Navigator import kotlinx.coroutines.launch @@ -24,19 +25,32 @@ internal class VerificationViewModel @Inject constructor( private val logger: Logger, val linkAccount: LinkAccount ) : ViewModel() { + /** + * Callback when user has successfully verified their account. If not overridden, defaults to + * navigating to the Wallet screen using [Navigator]. + */ + var onVerificationCompleted: () -> Unit = { + navigator.navigateTo(LinkScreen.Wallet, clearBackStack = true) + } + + init { + if (linkAccount.accountStatus != AccountStatus.VerificationStarted) { + startVerification() + } + } fun onVerificationCodeEntered(code: String) { viewModelScope.launch { linkAccountManager.confirmVerification(code).fold( onSuccess = { - navigator.navigateTo(LinkScreen.Wallet, clearBackStack = true) + onVerificationCompleted() }, onFailure = ::onError ) } } - fun onResendCodeClicked() { + fun startVerification() { viewModelScope.launch { linkAccountManager.startVerification().fold( onSuccess = { diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt index 7fee161e2b8..f59ccb7a080 100644 --- a/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt +++ b/link/src/test/java/com/stripe/android/link/LinkActivityTest.kt @@ -2,48 +2,106 @@ package com.stripe.android.link import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat import com.stripe.android.PaymentConfiguration +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.confirmation.ConfirmationManager +import com.stripe.android.link.model.AccountStatus +import com.stripe.android.link.model.Navigator import com.stripe.android.link.model.StripeIntentFixtures import com.stripe.android.link.utils.FakeAndroidKeyStore import com.stripe.android.link.utils.InjectableActivityScenario import com.stripe.android.link.utils.injectableActivityScenario -import org.junit.Before +import com.stripe.android.link.utils.viewModelFactoryFor +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.argWhere +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +@Ignore("Fix CircularProgressIndicator hanging tests") +@ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) class LinkActivityTest { private val context = ApplicationProvider.getApplicationContext() - private val intent = LinkActivityContract().createIntent( - context, - LinkActivityContract.Args( - StripeIntentFixtures.PI_SUCCEEDED, - "Example, Inc." - ) + private val args = LinkActivityContract.Args( + StripeIntentFixtures.PI_SUCCEEDED, + "Example, Inc." + ) + private val intent = LinkActivityContract().createIntent(context, args) + + private val linkAccountManager = mock().apply { + whenever(linkAccount).thenReturn(MutableStateFlow(mock())) + } + private val confirmationManager = mock() + private val navigator = mock() + + private val viewModel = LinkActivityViewModel( + args, + linkAccountManager, + navigator, + confirmationManager ) init { FakeAndroidKeyStore.setup() + PaymentConfiguration.init(context, "publishable_key") } - @Before - fun before() { - PaymentConfiguration.init(context, "publishable_key") + @Test + fun `When consumer does not exist then it navigates to SignUp screen`() { + whenever(linkAccountManager.accountStatus).thenReturn(flowOf(AccountStatus.SignedOut)) + + activityScenario().launch(intent).onActivity { + verify(navigator).navigateTo( + argWhere { + it.route.startsWith(LinkScreen.SignUp.route.substringBefore('?')) + }, + eq(true) + ) + } } @Test - fun `Activity launches sign up UI`() { - activityScenario().launch(intent).onActivity { activity -> - assertThat(activity.navController.currentDestination?.route) - .isEqualTo(LinkScreen.SignUp.route) + fun `When consumer is verified then it navigates to Wallet screen`() = runTest { + whenever(linkAccountManager.accountStatus).thenReturn(flowOf(AccountStatus.Verified)) + + activityScenario().launch(intent).onActivity { + verify(navigator).navigateTo( + argWhere { + it.route == LinkScreen.Wallet.route + }, + eq(true) + ) + } + } + + @Test + fun `When consumer is not verified then it navigates to Verification screen`() = runTest { + whenever(linkAccountManager.accountStatus).thenReturn(flowOf(AccountStatus.VerificationStarted)) + + activityScenario().launch(intent).onActivity { + verify(navigator).navigateTo( + argWhere { + it.route == LinkScreen.Verification.route + }, + eq(true) + ) } } private fun activityScenario(): InjectableActivityScenario { return injectableActivityScenario { - injectActivity {} + injectActivity { + viewModelFactory = viewModelFactoryFor(viewModel) + } } } } diff --git a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt index 6249c8859bf..80ffde46fe0 100644 --- a/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/LinkActivityViewModelTest.kt @@ -8,11 +8,9 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.core.injection.Injectable import com.stripe.android.core.injection.WeakMapInjectorRegistry -import com.stripe.android.link.account.CookieStore import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.confirmation.ConfirmationManager import com.stripe.android.link.injection.NonFallbackInjector -import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.Navigator import com.stripe.android.link.model.StripeIntentFixtures import com.stripe.android.link.utils.FakeAndroidKeyStore @@ -22,7 +20,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argWhere -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.spy @@ -50,61 +47,12 @@ class LinkActivityViewModelTest { private val linkAccountManager = mock() private val confirmationManager = mock() - private val cookieStore = mock() private val navigator = mock() init { FakeAndroidKeyStore.setup() } - @Test - fun `When consumer is logged out then start destination is SignUp screen`() = runTest { - whenever(cookieStore.isEmailLoggedOut(CUSTOMER_EMAIL)).thenReturn(true) - - val viewModel = createViewModel() - - assertThat(viewModel.startDestination).isEqualTo(LinkScreen.SignUp.route) - } - - @Test - fun `When consumer is verified then it navigates to Wallet screen`() = runTest { - val account = mock() - whenever(account.isVerified).thenReturn(true) - whenever(linkAccountManager.lookupConsumer(any())) - .thenReturn(Result.success(account)) - - createViewModel() - - verify(navigator).navigateTo(LinkScreen.Wallet, true) - } - - @Test - fun `When consumer is not verified then it navigates to Verification screen`() = runTest { - val account = mock() - whenever(account.isVerified).thenReturn(false) - whenever(linkAccountManager.lookupConsumer(any())) - .thenReturn(Result.success(account)) - - createViewModel() - - verify(navigator).navigateTo(LinkScreen.Verification, true) - } - - @Test - fun `When consumer does not exist then it navigates to SignUp screen`() = runTest { - whenever(linkAccountManager.lookupConsumer(any())) - .thenReturn(Result.success(null)) - - createViewModel() - - verify(navigator).navigateTo( - argWhere { - it.route == LinkScreen.SignUp(CUSTOMER_EMAIL).route - }, - eq(true) - ) - } - @Test fun `When StripeIntent is missing required fields then it dismisses with error`() = runTest { createViewModel( @@ -145,7 +93,7 @@ class LinkActivityViewModelTest { WeakMapInjectorRegistry.register(injector, INJECTOR_KEY) val factory = LinkActivityViewModel.Factory( - ApplicationProvider.getApplicationContext(), + { ApplicationProvider.getApplicationContext() }, { defaultArgs } ) val factorySpy = spy(factory) @@ -168,7 +116,7 @@ class LinkActivityViewModelTest { val context = ApplicationProvider.getApplicationContext() val factory = LinkActivityViewModel.Factory( - ApplicationProvider.getApplicationContext(), + { ApplicationProvider.getApplicationContext() }, { defaultArgs } ) val factorySpy = spy(factory) @@ -185,7 +133,6 @@ class LinkActivityViewModelTest { LinkActivityViewModel( args, linkAccountManager, - cookieStore, navigator, confirmationManager ) diff --git a/link/src/test/java/com/stripe/android/link/LinkPaymentLauncherTest.kt b/link/src/test/java/com/stripe/android/link/LinkPaymentLauncherTest.kt index d70867dbf4a..d44e166b754 100644 --- a/link/src/test/java/com/stripe/android/link/LinkPaymentLauncherTest.kt +++ b/link/src/test/java/com/stripe/android/link/LinkPaymentLauncherTest.kt @@ -1,8 +1,13 @@ package com.stripe.android.link +import android.content.Context import androidx.activity.result.ActivityResultLauncher +import androidx.test.core.app.ApplicationProvider import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.link.model.StripeIntentFixtures +import com.stripe.android.link.utils.FakeAndroidKeyStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.argWhere @@ -10,14 +15,16 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner +@ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) class LinkPaymentLauncherTest { - private val mockHostActivityLauncher = - mock>() + private val context = ApplicationProvider.getApplicationContext() + private val mockHostActivityLauncher = mock>() - private val linkPaymentLauncher = LinkPaymentLauncher( - mockHostActivityLauncher, - mock(), + private var linkPaymentLauncher = LinkPaymentLauncher( + MERCHANT_NAME, + null, + context, setOf(PRODUCT_USAGE), { PUBLISHABLE_KEY }, { STRIPE_ACCOUNT_ID }, @@ -29,18 +36,19 @@ class LinkPaymentLauncherTest { stripeRepository = mock() ) + init { + FakeAndroidKeyStore.setup() + } + @Test - fun `verify present() launches LinkActivity with correct arguments`() { + fun `verify present() launches LinkActivity with correct arguments`() = runTest { val stripeIntent = StripeIntentFixtures.PI_SUCCEEDED - linkPaymentLauncher.present( - stripeIntent, - MERCHANT_NAME - ) + linkPaymentLauncher.setup(stripeIntent) + linkPaymentLauncher.present(mockHostActivityLauncher) verify(mockHostActivityLauncher).launch( argWhere { arg -> arg.stripeIntent == stripeIntent && - arg.merchantName == MERCHANT_NAME && arg.injectionParams != null && arg.injectionParams.productUsage == setOf(PRODUCT_USAGE) && arg.injectionParams.injectorKey == LinkPaymentLauncher::class.simpleName + WeakMapInjectorRegistry.CURRENT_REGISTER_KEY.get() && diff --git a/link/src/test/java/com/stripe/android/link/ui/inline/InlineSignupViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/inline/InlineSignupViewModelTest.kt new file mode 100644 index 00000000000..5326c9f718b --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/ui/inline/InlineSignupViewModelTest.kt @@ -0,0 +1,120 @@ +package com.stripe.android.link.ui.inline + +import com.google.common.truth.Truth +import com.stripe.android.core.Logger +import com.stripe.android.link.account.LinkAccountManager +import com.stripe.android.link.model.LinkAccount +import com.stripe.android.link.ui.signup.SignUpState +import com.stripe.android.link.ui.signup.SignUpViewModel +import com.stripe.android.model.ConsumerSession +import com.stripe.android.ui.core.elements.IdentifierSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class InlineSignupViewModelTest { + private val linkAccountManager = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `When email is provided it should not trigger lookup and should collect phone number`() = + runTest(UnconfinedTestDispatcher()) { + val viewModel = createViewModel() + viewModel.toggleExpanded() + Truth.assertThat(viewModel.signUpState.value).isEqualTo(SignUpState.InputtingPhone) + + verify(linkAccountManager, times(0)).lookupConsumer(any()) + } + + @Test + fun `When entered existing account then it becomes ready`() = + runTest(UnconfinedTestDispatcher()) { + val viewModel = createViewModel() + viewModel.toggleExpanded() + viewModel.emailElement.setRawValue(mapOf(IdentifierSpec.Email to "valid@email.com")) + + val linkAccount = LinkAccount( + mockConsumerSessionWithVerificationSession( + ConsumerSession.VerificationSession.SessionType.Sms, + ConsumerSession.VerificationSession.SessionState.Started + ) + ) + whenever(linkAccountManager.lookupConsumer(any())) + .thenReturn(Result.success(linkAccount)) + + // Advance past lookup debounce delay + advanceTimeBy(SignUpViewModel.LOOKUP_DEBOUNCE_MS + 100) + + Truth.assertThat(viewModel.isReady.value).isTrue() + } + + @Test + fun `When entered non-existing account then it collects phone number`() = + runTest(UnconfinedTestDispatcher()) { + val viewModel = createViewModel() + viewModel.toggleExpanded() + viewModel.emailElement.setRawValue(mapOf(IdentifierSpec.Email to "valid@email.com")) + + whenever(linkAccountManager.lookupConsumer(any())) + .thenReturn(Result.success(null)) + + // Advance past lookup debounce delay + advanceTimeBy(SignUpViewModel.LOOKUP_DEBOUNCE_MS + 100) + + Truth.assertThat(viewModel.isReady.value).isFalse() + Truth.assertThat(viewModel.signUpState.value).isEqualTo(SignUpState.InputtingPhone) + } + + private fun createViewModel() = InlineSignupViewModel( + merchantName = MERCHANT_NAME, + customerEmail = CUSTOMER_EMAIL, + linkAccountManager = linkAccountManager, + logger = Logger.noop() + ) + + private fun mockConsumerSessionWithVerificationSession( + type: ConsumerSession.VerificationSession.SessionType, + state: ConsumerSession.VerificationSession.SessionState + ): ConsumerSession { + val verificationSession = mock() + whenever(verificationSession.type).thenReturn(type) + whenever(verificationSession.state).thenReturn(state) + val verificationSessions = listOf(verificationSession) + + val consumerSession = mock() + whenever(consumerSession.verificationSessions).thenReturn(verificationSessions) + whenever(consumerSession.clientSecret).thenReturn("secret") + whenever(consumerSession.emailAddress).thenReturn("email") + return consumerSession + } + + private companion object { + const val MERCHANT_NAME = "merchantName" + const val CUSTOMER_EMAIL = "customer@email.com" + } +} diff --git a/link/src/test/java/com/stripe/android/link/ui/signup/SignUpViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/signup/SignUpViewModelTest.kt index 0baa349be2c..45079cf7ae0 100644 --- a/link/src/test/java/com/stripe/android/link/ui/signup/SignUpViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/ui/signup/SignUpViewModelTest.kt @@ -218,7 +218,7 @@ class SignUpViewModelTest { args: LinkActivityContract.Args = defaultArgs ) = SignUpViewModel( args = args, - prefilledEmail = prefilledEmail, + customerEmail = prefilledEmail, linkAccountManager = linkAccountManager, logger = Logger.noop(), navigator = navigator diff --git a/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt index d5e869f9dd1..db6065242fe 100644 --- a/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/ui/verification/VerificationViewModelTest.kt @@ -12,6 +12,7 @@ import com.stripe.android.link.LinkScreen import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.injection.NonFallbackInjector import com.stripe.android.link.injection.SignedInViewModelSubcomponent +import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.Navigator import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,7 +33,9 @@ class VerificationViewModelTest { private val linkAccountManager = mock() private val navigator = mock() private val logger = Logger.noop() - private val linkAccount = mock() + private val linkAccount = mock().apply { + whenever(accountStatus).thenReturn(AccountStatus.VerificationStarted) + } @Test fun `onResendCodeClicked triggers verification start`() = runTest { @@ -40,7 +43,7 @@ class VerificationViewModelTest { .thenReturn(Result.success(mock())) val viewModel = createViewModel() - viewModel.onResendCodeClicked() + viewModel.startVerification() verify(linkAccountManager).startVerification() } diff --git a/link/src/test/java/com/stripe/android/link/utils/InjectableActivityScenario.kt b/link/src/test/java/com/stripe/android/link/utils/InjectableActivityScenario.kt index c4cec09b202..e7d62afcc23 100644 --- a/link/src/test/java/com/stripe/android/link/utils/InjectableActivityScenario.kt +++ b/link/src/test/java/com/stripe/android/link/utils/InjectableActivityScenario.kt @@ -8,6 +8,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.test.core.app.ActivityScenario import androidx.test.runner.lifecycle.ActivityLifecycleCallback import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry @@ -192,3 +194,10 @@ class InjectableActivityScenario(private val activityClass: Class< } } } + +@Suppress("UNCHECKED_CAST") +fun viewModelFactoryFor(viewModel: ViewModel) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return viewModel as T + } +} diff --git a/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt b/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt index a7635d25f2c..daa9e9e4d22 100644 --- a/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt +++ b/payments-core/src/main/java/com/stripe/android/networking/StripeApiRepository.kt @@ -1159,7 +1159,7 @@ internal class StripeApiRepository @JvmOverloads internal constructor( * Retrieves the ConsumerSession if the given email is associated with a Link account. */ override suspend fun lookupConsumerSession( - email: String, + email: String?, authSessionCookie: String?, requestOptions: ApiRequest.Options ): ConsumerSessionLookup? { @@ -1167,15 +1167,20 @@ internal class StripeApiRepository @JvmOverloads internal constructor( apiRequestFactory.createPost( consumerSessionLookupUrl, requestOptions, - mapOf("email_address" to email.lowercase()) - .plus( - authSessionCookie?.let { - mapOf( - "cookies" to - mapOf("verification_session_client_secrets" to listOf(it)) - ) - } ?: emptyMap() - ) + ( + email?.let { + mapOf( + "email_address" to it.lowercase() + ) + } ?: emptyMap() + ).plus( + authSessionCookie?.let { + mapOf( + "cookies" to + mapOf("verification_session_client_secrets" to listOf(it)) + ) + } ?: emptyMap() + ) ), ConsumerSessionLookupJsonParser() ) { diff --git a/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt b/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt index 5285516e8f3..1a4ecc8fe58 100644 --- a/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt +++ b/payments-core/src/main/java/com/stripe/android/networking/StripeRepository.kt @@ -397,7 +397,7 @@ abstract class StripeRepository { @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) abstract suspend fun lookupConsumerSession( - email: String, + email: String?, authSessionCookie: String?, requestOptions: ApiRequest.Options ): ConsumerSessionLookup? diff --git a/payments-core/src/test/java/com/stripe/android/networking/AbsFakeStripeRepository.kt b/payments-core/src/test/java/com/stripe/android/networking/AbsFakeStripeRepository.kt index a80b0a5c728..26778721529 100644 --- a/payments-core/src/test/java/com/stripe/android/networking/AbsFakeStripeRepository.kt +++ b/payments-core/src/test/java/com/stripe/android/networking/AbsFakeStripeRepository.kt @@ -282,7 +282,7 @@ internal abstract class AbsFakeStripeRepository : StripeRepository() { ) = RadarSession("rse_abc123") override suspend fun lookupConsumerSession( - email: String, + email: String?, authSessionCookie: String?, requestOptions: ApiRequest.Options ): ConsumerSessionLookup? { diff --git a/paymentsheet/api/paymentsheet.api b/paymentsheet/api/paymentsheet.api index 33f4efa7dc9..54c386ae269 100644 --- a/paymentsheet/api/paymentsheet.api +++ b/paymentsheet/api/paymentsheet.api @@ -353,6 +353,7 @@ public final class com/stripe/android/paymentsheet/databinding/ActivityPaymentOp public final field fragmentContainer Landroidx/fragment/app/FragmentContainerView; public final field fragmentContainerParent Landroid/widget/LinearLayout; public final field header Landroidx/compose/ui/platform/ComposeView; + public final field linkAuth Landroidx/compose/ui/platform/ComposeView; public final field message Landroid/widget/TextView; public final field notes Landroidx/compose/ui/platform/ComposeView; public final field scrollView Landroid/widget/ScrollView; @@ -377,7 +378,8 @@ public final class com/stripe/android/paymentsheet/databinding/ActivityPaymentSh public final field googlePayButton Lcom/stripe/android/paymentsheet/ui/GooglePayButton; public final field googlePayDivider Landroidx/compose/ui/platform/ComposeView; public final field header Landroidx/compose/ui/platform/ComposeView; - public final field linkButton Lcom/stripe/android/link/ui/LinkViewButton; + public final field linkAuth Landroidx/compose/ui/platform/ComposeView; + public final field linkButton Lcom/stripe/android/link/ui/LinkButtonView; public final field message Landroid/widget/TextView; public final field notes Landroidx/compose/ui/platform/ComposeView; public final field scrollView Landroid/widget/ScrollView; @@ -410,6 +412,7 @@ public final class com/stripe/android/paymentsheet/databinding/FragmentPaymentsh } public final class com/stripe/android/paymentsheet/databinding/FragmentPaymentsheetAddPaymentMethodBinding : androidx/viewbinding/ViewBinding { + public final field linkInlineSignup Lcom/stripe/android/link/ui/inline/LinkInlineSignupView; public final field paymentMethodFragmentContainer Landroidx/fragment/app/FragmentContainerView; public final field paymentMethodsRecycler Landroidx/compose/ui/platform/ComposeView; public static fun bind (Landroid/view/View;)Lcom/stripe/android/paymentsheet/databinding/FragmentPaymentsheetAddPaymentMethodBinding; diff --git a/paymentsheet/res/layout/activity_payment_options.xml b/paymentsheet/res/layout/activity_payment_options.xml index 7a0cefa3043..676f0611a45 100644 --- a/paymentsheet/res/layout/activity_payment_options.xml +++ b/paymentsheet/res/layout/activity_payment_options.xml @@ -16,6 +16,12 @@ android:background="?colorSurface" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> + + + + + + - + + 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 a036e44a992..fdbcffc92f3 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt @@ -18,6 +18,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.link.model.AccountStatus import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import com.stripe.android.model.StripeIntent @@ -84,9 +85,26 @@ internal abstract class BaseAddPaymentMethodFragment : Fragment() { } sheetViewModel.processing.observe(viewLifecycleOwner) { isProcessing -> + viewBinding.linkInlineSignup.isEnabled = !isProcessing (getFragment() as? ComposeFormDataCollectionFragment)?.setProcessing(isProcessing) } + viewBinding.linkInlineSignup.apply { + linkLauncher = sheetViewModel.linkLauncher + } + + // isLinkEnabled is set during initialization and never changes, so we can just use the + // current value + sheetViewModel.isLinkEnabled.value?.takeIf { it }?.let { + lifecycleScope.launch { + sheetViewModel.linkLauncher.accountStatus.collect { + // Show inline sign up view only if user is logged out + viewBinding.linkInlineSignup.isVisible = it == AccountStatus.SignedOut || + viewBinding.linkInlineSignup.hasUserInteracted + } + } + } + // 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 -> 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 aef9a9e11c5..6a644073a84 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsActivity.kt @@ -60,6 +60,7 @@ internal class PaymentOptionsActivity : BaseSheetActivity() override val rootView: ViewGroup by lazy { viewBinding.root } override val bottomSheet: ViewGroup by lazy { viewBinding.bottomSheet } override val appbar: AppBarLayout by lazy { viewBinding.appbar } + override val linkAuthView: ComposeView by lazy { viewBinding.linkAuth } override val toolbar: MaterialToolbar by lazy { viewBinding.toolbar } override val testModeIndicator: TextView by lazy { viewBinding.testmode } override val scrollView: ScrollView by lazy { viewBinding.scrollView } 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 5c0f8b33a11..273bf7dc452 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsViewModel.kt @@ -113,10 +113,12 @@ internal class PaymentOptionsViewModel @Inject constructor( } override fun onLinkLaunched() { + super.onLinkLaunched() _processing.value = true } override fun onLinkPaymentResult(result: LinkActivityResult) { + super.onLinkPaymentResult(result) _processing.value = false } 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 b4ba2b66805..25ed0aa96fe 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt @@ -67,6 +67,7 @@ internal class PaymentSheetActivity : BaseSheetActivity() { override val rootView: ViewGroup by lazy { viewBinding.root } override val bottomSheet: ViewGroup by lazy { viewBinding.bottomSheet } override val appbar: AppBarLayout by lazy { viewBinding.appbar } + override val linkAuthView: ComposeView by lazy { viewBinding.linkAuth } override val toolbar: MaterialToolbar by lazy { viewBinding.toolbar } override val testModeIndicator: TextView by lazy { viewBinding.testmode } override val scrollView: ScrollView by lazy { viewBinding.scrollView } @@ -140,6 +141,7 @@ internal class PaymentSheetActivity : BaseSheetActivity() { linkButton.apply { onClick = viewModel::launchLink + linkPaymentLauncher = viewModel.linkLauncher } viewModel.transition.observe(this) { transitionEvent -> @@ -188,10 +190,6 @@ internal class PaymentSheetActivity : BaseSheetActivity() { closeSheet(it) } - viewModel.contentVisible.observe(this) { - viewBinding.scrollView.isVisible = it - } - viewModel.buttonsEnabled.observe(this) { enabled -> linkButton.isEnabled = enabled googlePayButton.isEnabled = enabled 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 bff137bcd83..26fddf1c6fb 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt @@ -123,10 +123,6 @@ internal class PaymentSheetViewModel @Inject internal constructor( internal val _viewState = MutableLiveData(null) internal val viewState: LiveData = _viewState.distinctUntilChanged() - @VisibleForTesting - internal val _contentVisible = MutableLiveData(true) - internal val contentVisible: LiveData = _contentVisible.distinctUntilChanged() - internal var checkoutIdentifier: CheckoutIdentifier = CheckoutIdentifier.SheetBottomBuy internal fun getButtonStateObservable( checkoutIdentifier: CheckoutIdentifier @@ -303,7 +299,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( private fun resetViewState(userErrorMessage: String? = null) { _viewState.value = PaymentSheetViewState.Reset(userErrorMessage?.let { UserErrorMessage(it) }) - savedStateHandle.set(SAVE_PROCESSING, false) + savedStateHandle[SAVE_PROCESSING] = false } private fun startProcessing(checkoutIdentifier: CheckoutIdentifier) { @@ -313,7 +309,7 @@ internal class PaymentSheetViewModel @Inject internal constructor( } this.checkoutIdentifier = checkoutIdentifier - savedStateHandle.set(SAVE_PROCESSING, true) + savedStateHandle[SAVE_PROCESSING] = true _viewState.value = PaymentSheetViewState.StartProcessing } @@ -390,10 +386,6 @@ internal class PaymentSheetViewModel @Inject internal constructor( paymentLauncher = null } - fun setContentVisible(visible: Boolean) { - _contentVisible.value = visible - } - private fun confirmPaymentSelection(paymentSelection: PaymentSelection?) { when (paymentSelection) { is PaymentSelection.Saved -> { @@ -408,14 +400,13 @@ internal class PaymentSheetViewModel @Inject internal constructor( } } - fun launchLink() { - } - override fun onLinkLaunched() { + super.onLinkLaunched() startProcessing(CheckoutIdentifier.SheetBottomBuy) } override fun onLinkPaymentResult(result: LinkActivityResult) { + super.onLinkPaymentResult(result) onPaymentResult(result.convertToPaymentResult()) } 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 87f90686aba..fc79e2f2f4f 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 @@ -30,6 +30,7 @@ import androidx.core.view.isVisible import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.stripe.android.link.ui.verification.LinkVerificationDialog import com.stripe.android.paymentsheet.BottomSheetController import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel @@ -55,6 +56,7 @@ internal abstract class BaseSheetActivity : AppCompatActivity() { abstract val rootView: ViewGroup abstract val bottomSheet: ViewGroup abstract val appbar: AppBarLayout + abstract val linkAuthView: ComposeView abstract val scrollView: ScrollView abstract val toolbar: MaterialToolbar abstract val messageView: TextView @@ -128,6 +130,25 @@ internal abstract class BaseSheetActivity : AppCompatActivity() { setupPrimaryButton() setupNotes() + viewModel.showLinkVerificationDialog.observe(this) { show -> + if (show) { + linkAuthView.setContent { + LinkVerificationDialog( + linkLauncher = viewModel.linkLauncher, + onDialogDismissed = viewModel::onLinkVerificationDismissed, + onVerificationCompleted = { + viewModel.launchLink() + viewModel.onLinkVerificationDismissed() + } + ) + } + } + } + + viewModel.contentVisible.observe(this) { + scrollView.isVisible = it + } + // Make `bottomSheet` clickable to prevent clicks on the bottom sheet from triggering // `rootView`'s click listener bottomSheet.isClickable = true 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 b197045df98..68f436457b2 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 @@ -142,6 +142,10 @@ internal abstract class BaseSheetViewModel( internal val _processing = savedStateHandle.getLiveData(SAVE_PROCESSING) val processing: LiveData = _processing + @VisibleForTesting + internal val _contentVisible = MutableLiveData(true) + internal val contentVisible: LiveData = _contentVisible.distinctUntilChanged() + /** * Use this to override the current UI state of the primary button. The UI state is reset every * time the payment selection is changed. @@ -156,6 +160,10 @@ internal abstract class BaseSheetViewModel( protected var linkActivityResultLauncher: ActivityResultLauncher? = null + val linkLauncher = linkPaymentLauncherFactory.create(merchantName, null) + + private val _showLinkVerificationDialog = MutableLiveData(false) + val showLinkVerificationDialog: LiveData = _showLinkVerificationDialog /** * This should be initialized from the starter args, and then from that @@ -361,6 +369,10 @@ internal abstract class BaseSheetViewModel( editing.value = isEditing } + fun setContentVisible(visible: Boolean) { + _contentVisible.value = visible + } + fun removePaymentMethod(paymentMethod: PaymentMethod) = runBlocking { launch { paymentMethod.id?.let { paymentMethodId -> @@ -380,19 +392,48 @@ internal abstract class BaseSheetViewModel( protected fun setupLink(unused: StripeIntent) { // TODO(brnunes-stripe): Enable Link +// if (stripeIntent.paymentMethodTypes.contains(PaymentMethod.Type.Link.code)) { +// viewModelScope.launch { +// when (linkLauncher.setup(stripeIntent)) { +// AccountStatus.Verified -> launchLink() +// AccountStatus.VerificationStarted, +// AccountStatus.NeedsVerification -> _showLinkVerificationDialog.value = true +// AccountStatus.SignedOut -> {} +// } +// _isLinkEnabled.value = true +// } +// } else { _isLinkEnabled.value = false +// } + } + + fun onLinkVerificationDismissed() { + _showLinkVerificationDialog.value = false + } + + fun launchLink() { + linkActivityResultLauncher?.let { activityResultLauncher -> + linkLauncher.present( + activityResultLauncher + ) + onLinkLaunched() + } } /** * Method called when the Link UI is launched. Should be used to update the PaymentSheet UI * accordingly. */ - abstract fun onLinkLaunched() + open fun onLinkLaunched() { + setContentVisible(false) + } /** * Method called with the result of a Link payment. */ - abstract fun onLinkPaymentResult(result: LinkActivityResult) + open fun onLinkPaymentResult(result: LinkActivityResult) { + setContentVisible(true) + } abstract fun onUserCancel()