From 30b9be65677dfcf9e2a1d6f8e5a9711df612a933 Mon Sep 17 00:00:00 2001 From: Till Hellmund <110940675+tillh-stripe@users.noreply.github.com> Date: Fri, 14 Oct 2022 13:39:58 -0400 Subject: [PATCH] Hook up UPI polling flow (#5704) * Hook up polling flow * Fix lint * Increase polling screen height * Fix build issue with @Preview annotations * Update API * Add correct failure icon --- paymentsheet/api/paymentsheet.api | 68 ++++- paymentsheet/build.gradle | 1 + ...stripe_ic_paymentsheet_polling_failure.xml | 21 ++ paymentsheet/res/values/totranslate.xml | 12 + .../polling/PollingActivity.kt | 2 +- .../polling/PollingAuthenticator.kt | 9 +- .../polling/PollingContract.kt | 3 + .../polling/PollingFragment.kt | 99 +++++++- .../polling/PollingScreen.kt | 240 ++++++++++++++++++ .../polling/PollingViewModel.kt | 60 +++++ .../polling/di/PollingComponent.kt | 44 ++++ .../polling/di/PollingViewModelModule.kt | 52 ++++ .../di/PollingViewModelSubcomponent.kt | 23 ++ 13 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 paymentsheet/res/drawable/stripe_ic_paymentsheet_polling_failure.xml create mode 100644 paymentsheet/res/values/totranslate.xml create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingComponent.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule.kt create mode 100644 paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelSubcomponent.kt diff --git a/paymentsheet/api/paymentsheet.api b/paymentsheet/api/paymentsheet.api index e4ac6ab861f..a444765529a 100644 --- a/paymentsheet/api/paymentsheet.api +++ b/paymentsheet/api/paymentsheet.api @@ -1519,13 +1519,21 @@ public final class com/stripe/android/paymentsheet/paymentdatacollection/ach/di/ public static fun providesProductUsage (Lcom/stripe/android/paymentsheet/paymentdatacollection/ach/di/USBankAccountFormViewModelModule;)Ljava/util/Set; } -public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/ComposableSingletons$PollingFragmentKt { - public static final field INSTANCE Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/ComposableSingletons$PollingFragmentKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/ComposableSingletons$PollingScreenKt { + public static final field INSTANCE Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/ComposableSingletons$PollingScreenKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function2; + public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-4 Lkotlin/jvm/functions/Function2; + public static field lambda-5 Lkotlin/jvm/functions/Function2; + public static field lambda-6 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$paymentsheet_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$paymentsheet_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$paymentsheet_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-3$paymentsheet_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-4$paymentsheet_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-5$paymentsheet_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-6$paymentsheet_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/DefaultTimeProvider_Factory : dagger/internal/Factory { @@ -1552,6 +1560,58 @@ public final class com/stripe/android/paymentsheet/paymentdatacollection/polling public static fun newInstance (Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel$Args;Lcom/stripe/android/polling/IntentStatusPoller;Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/TimeProvider;Lkotlinx/coroutines/CoroutineDispatcher;Landroidx/lifecycle/SavedStateHandle;)Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel; } +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel_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/paymentsheet/paymentdatacollection/polling/PollingViewModel$Factory;)V + public synthetic fun injectMembers (Ljava/lang/Object;)V + public static fun injectSubcomponentBuilderProvider (Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel$Factory;Ljavax/inject/Provider;)V +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/DaggerPollingComponent { + public static fun builder ()Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingComponent$Builder; +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidePaymentConfigurationFactory : dagger/internal/Factory { + public fun (Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidePaymentConfigurationFactory; + public fun get ()Lcom/stripe/android/PaymentConfiguration; + public synthetic fun get ()Ljava/lang/Object; + public static fun providePaymentConfiguration (Landroid/content/Context;)Lcom/stripe/android/PaymentConfiguration; +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidePublishableKeyFactory : dagger/internal/Factory { + public fun (Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidePublishableKeyFactory; + public synthetic fun get ()Ljava/lang/Object; + public fun get ()Lkotlin/jvm/functions/Function0; + public static fun providePublishableKey (Landroid/content/Context;)Lkotlin/jvm/functions/Function0; +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesAppContextFactory : dagger/internal/Factory { + public fun (Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesAppContextFactory; + public fun get ()Landroid/content/Context; + public synthetic fun get ()Ljava/lang/Object; + public static fun providesAppContext (Landroid/app/Application;)Landroid/content/Context; +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesEnableLoggingFactory : dagger/internal/Factory { + public fun ()V + public static fun create ()Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesEnableLoggingFactory; + public fun get ()Ljava/lang/Boolean; + public synthetic fun get ()Ljava/lang/Object; + public static fun providesEnableLogging ()Z +} + +public final class com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesProductUsageFactory : dagger/internal/Factory { + public fun ()V + public static fun create ()Lcom/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule_Companion_ProvidesProductUsageFactory; + public synthetic fun get ()Ljava/lang/Object; + public fun get ()Ljava/util/Set; + public static fun providesProductUsage ()Ljava/util/Set; +} + public final class com/stripe/android/paymentsheet/repositories/CustomerApiRepository_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/paymentsheet/repositories/CustomerApiRepository_Factory; diff --git a/paymentsheet/build.gradle b/paymentsheet/build.gradle index 386c44e8988..85898399f98 100644 --- a/paymentsheet/build.gradle +++ b/paymentsheet/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation "androidx.compose.runtime:runtime-livedata:$androidxComposeVersion" implementation "com.google.accompanist:accompanist-flowlayout:$accompanistVersion" // Tooling support (Previews, etc.) + implementation "androidx.compose.ui:ui-tooling-preview:$androidxComposeVersion" debugImplementation "androidx.compose.ui:ui-tooling:$androidxComposeVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion" diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_polling_failure.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_polling_failure.xml new file mode 100644 index 00000000000..84180a5c17e --- /dev/null +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_polling_failure.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/paymentsheet/res/values/totranslate.xml b/paymentsheet/res/values/totranslate.xml new file mode 100644 index 00000000000..0c5ad43ad14 --- /dev/null +++ b/paymentsheet/res/values/totranslate.xml @@ -0,0 +1,12 @@ + + + + + Approve payment + Open your UPI app to approve your payment within %s + Cancel and pay another way + + Payment failed + Please go back and try another payment method + + diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt index ba98ada64d9..b2b413cf96f 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt @@ -30,7 +30,7 @@ internal class PollingActivity : AppCompatActivity() { } if (savedInstanceState == null) { - val fragment = PollingFragment.newInstance() + val fragment = PollingFragment.newInstance(args) fragment.isCancelable = false fragment.show(supportFragmentManager, fragment.tag) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt index 0b9162d25f7..d9d7b4b5e7f 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt @@ -10,6 +10,10 @@ import com.stripe.android.payments.core.authentication.PaymentAuthenticator import com.stripe.android.view.AuthActivityStarterHost import javax.inject.Singleton +private const val UPI_TIME_LIMIT_IN_SECONDS = 5 * 60 +private const val UPI_INITIAL_DELAY_IN_SECONDS = 5 +private const val UPI_MAX_ATTEMPTS = 12 + @Singleton internal class PollingAuthenticator : PaymentAuthenticator { @@ -22,7 +26,10 @@ internal class PollingAuthenticator : PaymentAuthenticator { ) { val args = PollingContract.Args( clientSecret = requireNotNull(authenticatable.clientSecret), - statusBarColor = host.statusBarColor + statusBarColor = host.statusBarColor, + timeLimitInSeconds = UPI_TIME_LIMIT_IN_SECONDS, + initialDelayInSeconds = UPI_INITIAL_DELAY_IN_SECONDS, + maxAttempts = UPI_MAX_ATTEMPTS, ) pollingLauncher?.launch(args) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt index 692a50575cf..fbc16f62727 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt @@ -27,6 +27,9 @@ internal class PollingContract : internal data class Args( val clientSecret: String, @ColorInt val statusBarColor: Int?, + val timeLimitInSeconds: Int, + val initialDelayInSeconds: Int, + val maxAttempts: Int, ) : Parcelable { internal companion object { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingFragment.kt index d61700d1719..f2a7efa9329 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingFragment.kt @@ -4,16 +4,44 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.ui.Modifier +import androidx.activity.addCallback import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.stripe.android.StripeIntentResult +import com.stripe.android.payments.PaymentFlowResult import com.stripe.android.ui.core.PaymentsTheme +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +private const val KEY_POLLING_ARGS = "KEY_POLLING_ARGS" internal class PollingFragment : BottomSheetDialogFragment() { + private val args: PollingContract.Args by lazy { + requireNotNull(arguments?.getParcelable(KEY_POLLING_ARGS)) + } + + private val viewModel by viewModels { + PollingViewModel.Factory( + applicationSupplier = { requireActivity().application }, + argsSupplier = { + PollingViewModel.Args( + clientSecret = args.clientSecret, + timeLimit = args.timeLimitInSeconds.seconds, + initialDelay = args.initialDelayInSeconds.seconds, + maxAttempts = args.maxAttempts, + ) + }, + owner = this, + ) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -22,20 +50,71 @@ internal class PollingFragment : BottomSheetDialogFragment() { return ComposeView(requireContext()).apply { setContent { PaymentsTheme { - Text( - text = "Coming soon 🚧", - modifier = Modifier.padding(32.dp) - ) + PollingScreen(viewModel) } } } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().onBackPressedDispatcher.addCallback( + owner = viewLifecycleOwner, + enabled = false, + onBackPressed = {} + ) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(this@PollingFragment::handleUiState) + } + } + } + + private fun handleUiState(uiState: PollingUiState) { + if (uiState.pollingState == PollingState.Success) { + finishWithSuccess() + } else if (uiState.pollingState == PollingState.Canceled) { + finishWithCancellation() + } + } + + private fun finishWithSuccess() { + val successResult = PaymentFlowResult.Unvalidated( + clientSecret = args.clientSecret, + flowOutcome = StripeIntentResult.Outcome.SUCCEEDED, + ) + finishWithResult(successResult) + } + + private fun finishWithCancellation() { + val cancelResult = PaymentFlowResult.Unvalidated( + clientSecret = args.clientSecret, + flowOutcome = StripeIntentResult.Outcome.CANCELED, + canCancelSource = false, + ) + finishWithResult(cancelResult) + } + + private fun finishWithResult(paymentFlowResult: PaymentFlowResult.Unvalidated) { + setFragmentResult( + requestKey = KEY_FRAGMENT_RESULT, + result = paymentFlowResult.toBundle(), + ) + dismiss() + } + companion object { + const val KEY_FRAGMENT_RESULT = "KEY_FRAGMENT_RESULT_PollingFragment" - fun newInstance(): PollingFragment { - return PollingFragment() + fun newInstance(args: PollingContract.Args): PollingFragment { + return PollingFragment().apply { + arguments = bundleOf( + KEY_POLLING_ARGS to args + ) + } } } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt new file mode 100644 index 00000000000..574fca1af91 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt @@ -0,0 +1,240 @@ +package com.stripe.android.paymentsheet.paymentdatacollection.polling + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.stripe.android.paymentsheet.R +import com.stripe.android.ui.core.PaymentsTheme +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun PollingScreen( + viewModel: PollingViewModel, + modifier: Modifier = Modifier, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsState() + + DisposableEffect(lifecycleOwner) { + val observer = PollingLifecycleObserver(viewModel) + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + PollingScreen( + uiState = uiState, + onCancel = viewModel::handleCancel, + modifier = modifier.fillMaxHeight(fraction = 0.67f), + ) +} + +@Composable +private fun PollingScreen( + uiState: PollingUiState, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + when (uiState.pollingState) { + PollingState.Active, + PollingState.Success, + PollingState.Canceled -> { + ActivePolling( + remainingDuration = uiState.durationRemaining, + onCancel = onCancel, + modifier = modifier, + ) + } + PollingState.Failed -> { + FailedPolling( + onCancel = onCancel, + modifier = modifier, + ) + } + } +} + +@Composable +private fun ActivePolling( + remainingDuration: Duration, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxSize() + .padding(32.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = stringResource(R.string.upi_polling_header), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = rememberActivePollingMessage(remainingDuration), + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp), + ) + + TextButton(onClick = onCancel) { + Text(stringResource(R.string.upi_polling_cancel)) + } + } +} + +@Composable +private fun FailedPolling( + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier.fillMaxSize()) { + IconButton(onClick = onCancel) { + Icon( + painter = painterResource(R.drawable.stripe_ic_paymentsheet_back_enabled), + contentDescription = stringResource(R.string.back), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = dimensionResource(R.dimen.stripe_paymentsheet_outer_spacing_top), + horizontal = dimensionResource(R.dimen.stripe_paymentsheet_outer_spacing_horizontal), + ), + ) { + Image( + painter = painterResource(R.drawable.stripe_ic_paymentsheet_polling_failure), + contentDescription = null, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = stringResource(R.string.upi_polling_payment_failed_title), + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp), + ) + + Text( + text = stringResource(R.string.upi_polling_payment_failed_message), + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun rememberActivePollingMessage( + remainingDuration: Duration, +): AnnotatedString { + val context = LocalContext.current + val primaryColor = MaterialTheme.colors.primary + + return remember(remainingDuration) { + val remainingTime = remainingDuration.toComponents { minutes, seconds, _ -> + val paddedSeconds = seconds.toString().padStart(length = 2, padChar = '0') + "$minutes:$paddedSeconds" + } + + val message = context.getString(R.string.upi_polling_message, remainingTime) + + buildAnnotatedString { + append(message.removeSuffix(remainingTime)) + append(AnnotatedString(remainingTime, SpanStyle(primaryColor))) + } + } +} + +private class PollingLifecycleObserver( + private val viewModel: PollingViewModel, +) : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + viewModel.resumePolling() + } + + override fun onStop(owner: LifecycleOwner) { + viewModel.pausePolling() + super.onStop(owner) + } +} + +@Preview(heightDp = 400) +@Composable +private fun ActivePollingScreenPreview() { + PaymentsTheme { + Surface { + PollingScreen( + uiState = PollingUiState( + durationRemaining = 83.seconds, + pollingState = PollingState.Active, + ), + onCancel = {}, + ) + } + } +} + +@Preview(heightDp = 400) +@Composable +private fun FailedPollingScreenPreview() { + PaymentsTheme { + Surface { + PollingScreen( + uiState = PollingUiState( + durationRemaining = 83.seconds, + pollingState = PollingState.Failed, + ), + onCancel = {}, + ) + } + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt index 03340e7c289..927adc685cc 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt @@ -1,14 +1,23 @@ package com.stripe.android.paymentsheet.paymentdatacollection.polling +import android.app.Application +import android.os.Bundle import android.os.SystemClock +import androidx.lifecycle.AbstractSavedStateViewModelFactory import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.savedstate.SavedStateRegistryOwner import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY +import com.stripe.android.core.injection.Injectable import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.core.injection.injectWithFallback import com.stripe.android.model.StripeIntent +import com.stripe.android.paymentsheet.paymentdatacollection.polling.di.DaggerPollingComponent +import com.stripe.android.paymentsheet.paymentdatacollection.polling.di.PollingViewModelSubcomponent import com.stripe.android.polling.IntentStatusPoller import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,6 +27,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +import javax.inject.Provider import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds @@ -163,6 +173,56 @@ internal class PollingViewModel @Inject constructor( } } + internal class Factory( + private val applicationSupplier: () -> Application, + private val argsSupplier: () -> Args, + owner: SavedStateRegistryOwner, + defaultArgs: Bundle? = null + ) : AbstractSavedStateViewModelFactory(owner, defaultArgs), + Injectable { + internal data class FallbackInitializeParam( + val application: Application + ) + + @Inject + lateinit var subcomponentBuilderProvider: Provider + + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + savedStateHandle: SavedStateHandle + ): T { + val args = argsSupplier() + + injectWithFallback(args.injectorKey, FallbackInitializeParam(applicationSupplier())) + + return subcomponentBuilderProvider.get() + .args(args) + .savedStateHandle(savedStateHandle) + .build() + .viewModel as T + } + + override fun fallbackInitialize(arg: FallbackInitializeParam) { + val args = argsSupplier() + + val config = IntentStatusPoller.Config( + clientSecret = args.clientSecret, + maxAttempts = args.maxAttempts, + ) + + DaggerPollingComponent + .builder() + .application(arg.application) + .injectorKey(DUMMY_INJECTOR_KEY) + .config(config) + .ioDispatcher(Dispatchers.IO) + .build() + .inject(this) + } + } + data class Args( val clientSecret: String, val timeLimit: Duration, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingComponent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingComponent.kt new file mode 100644 index 00000000000..d12404969a8 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingComponent.kt @@ -0,0 +1,44 @@ +package com.stripe.android.paymentsheet.paymentdatacollection.polling.di + +import android.app.Application +import com.stripe.android.core.injection.CoreCommonModule +import com.stripe.android.core.injection.CoroutineContextModule +import com.stripe.android.core.injection.InjectorKey +import com.stripe.android.payments.core.injection.StripeRepositoryModule +import com.stripe.android.paymentsheet.paymentdatacollection.polling.PollingViewModel +import com.stripe.android.polling.IntentStatusPoller +import dagger.BindsInstance +import dagger.Component +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Singleton + +@Singleton +@Component( + modules = [ + CoroutineContextModule::class, + PollingViewModelModule::class, + StripeRepositoryModule::class, + CoreCommonModule::class + ] +) +internal interface PollingComponent { + fun inject(factory: PollingViewModel.Factory) + + @Component.Builder + interface Builder { + + @BindsInstance + fun application(application: Application): Builder + + @BindsInstance + fun config(config: IntentStatusPoller.Config): Builder + + @BindsInstance + fun ioDispatcher(dispatcher: CoroutineDispatcher): Builder + + @BindsInstance + fun injectorKey(@InjectorKey injectorKey: String): Builder + + fun build(): PollingComponent + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule.kt new file mode 100644 index 00000000000..f1f8a06a9df --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelModule.kt @@ -0,0 +1,52 @@ +package com.stripe.android.paymentsheet.paymentdatacollection.polling.di + +import android.app.Application +import android.content.Context +import com.stripe.android.PaymentConfiguration +import com.stripe.android.core.injection.ENABLE_LOGGING +import com.stripe.android.core.injection.PUBLISHABLE_KEY +import com.stripe.android.payments.core.injection.PRODUCT_USAGE +import com.stripe.android.paymentsheet.BuildConfig +import com.stripe.android.paymentsheet.paymentdatacollection.polling.DefaultTimeProvider +import com.stripe.android.paymentsheet.paymentdatacollection.polling.TimeProvider +import com.stripe.android.polling.DefaultIntentStatusPoller +import com.stripe.android.polling.IntentStatusPoller +import dagger.Binds +import dagger.Module +import dagger.Provides +import javax.inject.Named + +@Module(subcomponents = [PollingViewModelSubcomponent::class]) +internal interface PollingViewModelModule { + + @Binds + fun bindsIntentStatusPoller(impl: DefaultIntentStatusPoller): IntentStatusPoller + + @Binds + fun bindsTimeProvider(impl: DefaultTimeProvider): TimeProvider + + companion object { + + @Provides + fun providesAppContext(application: Application): Context = application + + @Provides + fun providePaymentConfiguration(appContext: Context): PaymentConfiguration { + return PaymentConfiguration.getInstance(appContext) + } + + @Provides + @Named(PUBLISHABLE_KEY) + fun providePublishableKey( + appContext: Context + ): () -> String = { PaymentConfiguration.getInstance(appContext).publishableKey } + + @Provides + @Named(PRODUCT_USAGE) + fun providesProductUsage(): Set = emptySet() + + @Provides + @Named(ENABLE_LOGGING) + fun providesEnableLogging(): Boolean = BuildConfig.DEBUG + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelSubcomponent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelSubcomponent.kt new file mode 100644 index 00000000000..c4dc4e05ce2 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/di/PollingViewModelSubcomponent.kt @@ -0,0 +1,23 @@ +package com.stripe.android.paymentsheet.paymentdatacollection.polling.di + +import androidx.lifecycle.SavedStateHandle +import com.stripe.android.paymentsheet.paymentdatacollection.polling.PollingViewModel +import dagger.BindsInstance +import dagger.Subcomponent + +@Subcomponent +internal interface PollingViewModelSubcomponent { + + val viewModel: PollingViewModel + + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun savedStateHandle(handle: SavedStateHandle): Builder + + @BindsInstance + fun args(args: PollingViewModel.Args): Builder + + fun build(): PollingViewModelSubcomponent + } +}