From a11c40e4fe7487e4f93b3c0083bd5849f5c04ba0 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 16 Nov 2022 12:10:26 -0400 Subject: [PATCH] Add credit card support to badge gifting. --- .../flow/GiftFlowConfirmationFragment.kt | 87 +++++--- .../gifts/flow/GiftFlowStartFragment.kt | 10 +- .../badges/gifts/flow/GiftFlowViewModel.kt | 109 +--------- .../badges/gifts/thanks/GiftThanksSheet.kt | 5 +- .../donate/DonateToSignalFragment.kt | 166 +++------------ .../donate/DonateToSignalState.kt | 8 + .../subscription/donate/DonateToSignalType.kt | 3 +- .../donate/DonateToSignalViewModel.kt | 20 +- .../donate/DonationCheckoutDelegate.kt | 193 ++++++++++++++++++ .../donate/DonationCheckoutViewModel.kt | 30 +++ .../subscription/donate/DonationPillToggle.kt | 3 + .../donate/gateway/GatewayRequest.kt | 7 +- .../gateway/GatewaySelectorBottomSheet.kt | 20 ++ .../StripePaymentInProgressViewModel.kt | 20 +- .../layout/gift_flow_confirmation_content.xml | 25 +-- app/src/main/res/navigation/gift_flow.xml | 87 +++++++- app/src/main/res/values/strings.xml | 2 + 17 files changed, 464 insertions(+), 331 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index 2b1410bb07..0a60ac05b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -4,10 +4,12 @@ import android.content.DialogInterface import android.view.KeyEvent import android.widget.FrameLayout import android.widget.ImageView +import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.subjects.PublishSubject @@ -20,8 +22,10 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource @@ -33,10 +37,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment +import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.Debouncer import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate /** * Allows the user to confirm details about a gift, add a message, and finally make a payment. @@ -48,7 +53,8 @@ class GiftFlowConfirmationFragment : ), EmojiKeyboardPageFragment.Callback, EmojiEventListener, - EmojiSearchFragment.Callback { + EmojiSearchFragment.Callback, + DonationCheckoutDelegate.Callback { companion object { private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java) @@ -67,9 +73,9 @@ class GiftFlowConfirmationFragment : private val lifecycleDisposable = LifecycleDisposable() private var errorDialog: DialogInterface? = null + private var donationCheckoutDelegate: DonationCheckoutDelegate? = null private lateinit var processingDonationPaymentDialog: AlertDialog private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog - private lateinit var donationPaymentComponent: DonationPaymentComponent private lateinit var textInputViewHolder: TextInput.MultilineViewHolder private val eventPublisher = PublishSubject.create() @@ -81,7 +87,7 @@ class GiftFlowConfirmationFragment : keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) - donationPaymentComponent = requireListener() + donationCheckoutDelegate = DonationCheckoutDelegate(this, this) processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext()) .setView(R.layout.processing_payment_dialog) @@ -98,13 +104,29 @@ class GiftFlowConfirmationFragment : emojiKeyboard.setFragmentManager(childFragmentManager) - val googlePayButton = requireView().findViewById(R.id.google_pay_button) - googlePayButton.setOnGooglePayClickListener { - viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time)) + val continueButton = requireView().findViewById(R.id.continue_button) + continueButton.setOnClickListener { + findNavController().safeNavigate( + GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet( + with(viewModel.snapshot) { + GatewayRequest( + donateToSignalType = DonateToSignalType.GIFT, + badge = giftBadge!!, + label = getString(R.string.preferences__one_time), + price = giftPrices[currency]!!.amount, + currencyCode = currency.currencyCode, + level = giftLevel!!, + recipientId = recipient!!.id, + additionalMessage = additionalMessage?.toString() + ) + } + ) + ) } val textInput = requireView().findViewById(R.id.text_input) val emojiToggle = textInput.findViewById(R.id.emoji_toggle) + val amountView = requireView().findViewById(R.id.amount) textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher) textInputViewHolder.onAttachedToWindow() @@ -165,29 +187,17 @@ class GiftFlowConfirmationFragment : } else { processingDonationPaymentDialog.dismiss() } + + amountView.text = FiatMoneyUtil.format(resources, state.giftPrices[state.currency]!!, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) } lifecycleDisposable.bindTo(viewLifecycleOwner) - lifecycleDisposable += DonationError .getErrorsForSource(DonationErrorSource.GIFT) .observeOn(AndroidSchedulers.mainThread()) .subscribe { donationError -> onPaymentError(donationError) } - - lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent -> - when (donationEvent) { - is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed() - DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay") - DonationEvent.SubscriptionCancelled -> Unit - is DonationEvent.SubscriptionCancellationFailed -> Unit - } - } - - lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe { - viewModel.onActivityResult(it.requestCode, it.resultCode, it.data) - } } override fun onDestroyView() { @@ -196,6 +206,7 @@ class GiftFlowConfirmationFragment : processingDonationPaymentDialog.dismiss() debouncer.clear() verifyingRecipientDonationPaymentDialog.dismiss() + donationCheckoutDelegate = null } private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration { @@ -225,16 +236,6 @@ class GiftFlowConfirmationFragment : } } - private fun onPaymentConfirmed() { - val mainActivityIntent = MainActivity.clearTop(requireContext()) - val conversationIntent = ConversationIntents - .createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L) - .withGiftBadge(viewModel.snapshot.giftBadge!!) - .build() - - requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent)) - } - private fun onPaymentError(throwable: Throwable?) { Log.w(TAG, "onPaymentError", throwable, true) @@ -276,4 +277,24 @@ class GiftFlowConfirmationFragment : eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent)) } } + + override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest)) + } + + override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest)) + } + + override fun onPaymentComplete(gatewayRequest: GatewayRequest) { + val mainActivityIntent = MainActivity.clearTop(requireContext()) + val conversationIntent = ConversationIntents + .createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L) + .withGiftBadge(viewModel.snapshot.giftBadge!!) + .build() + + requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent)) + } + + override fun onProcessorActionProcessed() = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt index eb10a3928e..a4b7905b15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt @@ -9,18 +9,14 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle import org.thoughtcrime.securesms.components.settings.models.SplashImage -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate /** @@ -33,11 +29,7 @@ class GiftFlowStartFragment : DSLSettingsFragment( private val viewModel: GiftFlowViewModel by viewModels( ownerProducer = { requireActivity() }, factoryProducer = { - GiftFlowViewModel.Factory( - GiftFlowRepository(), - requireListener().stripeRepository, - OneTimeDonationRepository(ApplicationDependencies.getDonationsService()) - ) + GiftFlowViewModel.Factory(GiftFlowRepository()) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index aeaa11a4ad..a17e23f8bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -1,14 +1,9 @@ package org.thoughtcrime.securesms.badges.gifts.flow -import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.google.android.gms.wallet.PaymentData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.plusAssign @@ -16,19 +11,9 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.subjects.PublishSubject import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney -import org.signal.donations.GooglePayApi -import org.signal.donations.GooglePayPaymentSource -import org.signal.donations.StripeApi -import org.signal.donations.StripeIntentAccessor -import org.thoughtcrime.securesms.badges.gifts.Gifts import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.InternetConnectionObserver @@ -39,13 +24,9 @@ import java.util.Currency * Maintains state as a user works their way through the gift flow. */ class GiftFlowViewModel( - private val giftFlowRepository: GiftFlowRepository, - private val stripeRepository: StripeRepository, - private val oneTimeDonationRepository: OneTimeDonationRepository + private val giftFlowRepository: GiftFlowRepository ) : ViewModel() { - private var giftToPurchase: Gift? = null - private val store = RxStore( GiftFlowState( currency = SignalStore.donationsValues().getOneTimeCurrency() @@ -133,86 +114,6 @@ class GiftFlowViewModel( return store.state.giftPrices.keys.map { it.currencyCode } } - fun requestTokenFromGooglePay(label: String) { - val giftLevel = store.state.giftLevel ?: return - val giftPrice = store.state.giftPrices[store.state.currency] ?: return - val giftRecipient = store.state.recipient?.id ?: return - - this.giftToPurchase = Gift(giftLevel, giftPrice) - - store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) } - disposables += giftFlowRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy( - onComplete = { - store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) } - stripeRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE) - }, - onError = this::onPaymentFlowError - ) - } - - fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent? - ) { - val gift = giftToPurchase - giftToPurchase = null - - val recipient = store.state.recipient?.id - - stripeRepository.onActivityResult( - requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE, - object : GooglePayApi.PaymentRequestCallback { - override fun onSuccess(paymentData: PaymentData) { - if (gift != null && recipient != null) { - eventPublisher.onNext(DonationEvent.RequestTokenSuccess) - - store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) } - - val continuePayment: Single = stripeRepository.continuePayment(gift.price, recipient, gift.level) - val intentAndSource: Single> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair) - - disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> - stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient) - .flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts. - .andThen(oneTimeDonationRepository.waitForOneTimeRedemption(gift.price, paymentIntent.intentId, recipient, store.state.additionalMessage?.toString(), gift.level)) - }.subscribeBy( - onError = this@GiftFlowViewModel::onPaymentFlowError, - onComplete = { - store.update { it.copy(stage = GiftFlowState.Stage.READY) } - eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!)) - } - ) - } else { - store.update { it.copy(stage = GiftFlowState.Stage.READY) } - } - } - - override fun onError(googlePayException: GooglePayApi.GooglePayException) { - store.update { it.copy(stage = GiftFlowState.Stage.READY) } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.GIFT, googlePayException)) - } - - override fun onCancelled() { - store.update { it.copy(stage = GiftFlowState.Stage.READY) } - } - } - ) - } - - private fun onPaymentFlowError(throwable: Throwable) { - store.update { it.copy(stage = GiftFlowState.Stage.READY) } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - Log.w(TAG, "Failed to complete payment or redemption", throwable, true) - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT) - } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) - } - private fun getLoadState( oldState: GiftFlowState, giftPrices: Map? = null, @@ -250,16 +151,12 @@ class GiftFlowViewModel( } class Factory( - private val repository: GiftFlowRepository, - private val stripeRepository: StripeRepository, - private val oneTimeDonationRepository: OneTimeDonationRepository + private val repository: GiftFlowRepository ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return modelClass.cast( GiftFlowViewModel( - repository, - stripeRepository, - oneTimeDonationRepository + repository ) ) as T } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt index 46ec2f395b..ab11988cfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt @@ -62,7 +62,10 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() { ) noPadTextPref( - title = DSLSettingsText.from(getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext()))) + title = DSLSettingsText.from( + getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())), + DSLSettingsText.CenterModifier + ) ) space(DimensionUnit.DP.toPixels(37f).toInt()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index a3ac52a643..7f09ca4218 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -8,23 +8,17 @@ import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment -import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import androidx.navigation.navGraphViewModels import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.LottieAnimationView -import com.google.android.gms.wallet.PaymentData import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.dp import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney -import org.signal.donations.GooglePayApi import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.models.BadgePreview import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout @@ -33,16 +27,8 @@ import org.thoughtcrime.securesms.components.WrapperDialogFragment import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource @@ -57,16 +43,17 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate import java.util.Currency /** * Unified donation fragment which allows users to choose between monthly or one-time donations. */ -class DonateToSignalFragment : DSLSettingsFragment( - layoutId = R.layout.donate_to_signal_fragment -) { +class DonateToSignalFragment : + DSLSettingsFragment( + layoutId = R.layout.donate_to_signal_fragment + ), + DonationCheckoutDelegate.Callback { companion object { private val TAG = Log.tag(DonateToSignalFragment::class.java) @@ -98,18 +85,10 @@ class DonateToSignalFragment : DSLSettingsFragment( DonateToSignalViewModel.Factory(args.startType) }) - private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels( - R.id.donate_to_signal, - factoryProducer = { - donationPaymentComponent = requireListener() - StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository) - } - ) - private val disposables = LifecycleDisposable() private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind) - private lateinit var donationPaymentComponent: DonationPaymentComponent + private var donationCheckoutDelegate: DonationCheckoutDelegate? = null private val supportTechSummary: CharSequence by lazy { SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging))) @@ -133,23 +112,7 @@ class DonateToSignalFragment : DSLSettingsFragment( } override fun bindAdapter(adapter: MappingAdapter) { - donationPaymentComponent = requireListener() - registerGooglePayCallback() - - setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle -> - val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!! - handleGatewaySelectionResponse(response) - } - - setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle -> - val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!! - handleDonationProcessorActionResult(result) - } - - setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle -> - val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!! - handleCreditCardResult(result) - } + donationCheckoutDelegate = DonationCheckoutDelegate(this, this) val recyclerView = this.recyclerView!! recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS @@ -242,6 +205,11 @@ class DonateToSignalFragment : DSLSettingsFragment( } } + override fun onDestroyView() { + super.onDestroyView() + donationCheckoutDelegate = null + } + private fun getConfiguration(state: DonateToSignalState): DSLConfiguration { return configure { space(36.dp) @@ -419,84 +387,6 @@ class DonateToSignalFragment : DSLSettingsFragment( } } - private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) { - when (gatewayResponse.gateway) { - GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse) - GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.") - GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse) - } - } - - private fun handleCreditCardResult(creditCardResult: CreditCardResult) { - Log.d(TAG, "Received credit card information from fragment.") - stripePaymentViewModel.provideCardData(creditCardResult.creditCardData) - findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest)) - } - - private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) { - when (result.status) { - DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result) - DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result) - } - - viewModel.refreshActiveSubscription() - } - - private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) { - if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) { - Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show() - } else { - findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge)) - } - } - - private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) { - if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__failed_to_cancel_subscription) - .setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection) - .setPositiveButton(android.R.string.ok) { _, _ -> - findNavController().popBackStack() - } - .show() - } else { - Log.w(TAG, "Stripe action failed: ${result.action}") - } - } - - private fun launchGooglePay(gatewayResponse: GatewayResponse) { - viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request) - donationPaymentComponent.stripeRepository.requestTokenFromGooglePay( - price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)), - label = gatewayResponse.request.label, - requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt() - ) - } - - private fun launchCreditCard(gatewayResponse: GatewayResponse) { - if (InAppDonations.isCreditCardAvailable()) { - findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayResponse.request)) - } else { - error("Credit cards are not currently enabled.") - } - } - - private fun registerGooglePayCallback() { - disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy( - onNext = { paymentResult -> - viewModel.consumeGatewayRequestForGooglePay()?.let { - donationPaymentComponent.stripeRepository.onActivityResult( - paymentResult.requestCode, - paymentResult.resultCode, - paymentResult.data, - paymentResult.requestCode, - GooglePayRequestCallback(it) - ) - } - } - ) - } - private fun showErrorDialog(throwable: Throwable) { if (errorDialog != null) { Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true) @@ -543,29 +433,19 @@ class DonateToSignalFragment : DSLSettingsFragment( } } - inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback { - override fun onSuccess(paymentData: PaymentData) { - Log.d(TAG, "Successfully retrieved payment data from Google Pay", true) - stripePaymentViewModel.providePaymentData(paymentData) - findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, request)) - } + override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest)) + } - override fun onError(googlePayException: GooglePayApi.GooglePayException) { - Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true) + override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest)) + } - val error = DonationError.getGooglePayRequestTokenError( - source = when (request.donateToSignalType) { - DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION - DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST - }, - throwable = googlePayException - ) + override fun onPaymentComplete(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge)) + } - DonationError.routeDonationError(requireContext(), error) - } - - override fun onCancelled() { - Log.d(TAG, "Cancelled Google Pay.", true) - } + override fun onProcessorActionProcessed() { + viewModel.refreshActiveSubscription() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt index c9d0c00c82..dcb29929a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -21,48 +21,56 @@ data class DonateToSignalState( get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val badge: Badge? get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val canSetCurrency: Boolean get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> areFieldsEnabled DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val selectedCurrency: Currency get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val selectableCurrencyCodes: List get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val level: Int get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> 1 DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val canContinue: Boolean get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable() DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable() + DonateToSignalType.GIFT -> error("This flow does not support gifts") } val canUpdate: Boolean get() = when (donateToSignalType) { DonateToSignalType.ONE_TIME -> false DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid + DonateToSignalType.GIFT -> error("This flow does not support gifts") } data class OneTimeDonationState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt index d449383e25..5da3315581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt @@ -6,5 +6,6 @@ import kotlinx.parcelize.Parcelize @Parcelize enum class DonateToSignalType(val requestCode: Short) : Parcelable { ONE_TIME(16141), - MONTHLY(16142); + MONTHLY(16142), + GIFT(16143) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index 6621ec5262..98a3b95944 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.manage.Su import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.subscription.Subscription @@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.util.next import org.thoughtcrime.securesms.util.rx.RxStore import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.SubscriberId -import org.whispersystems.signalservice.api.util.Preconditions import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -57,8 +57,6 @@ class DonateToSignalViewModel( private val _actions = PublishSubject.create() private val _activeSubscription = PublishSubject.create() - private var gatewayRequest: GatewayRequest? = null - val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val actions: Observable = _actions.observeOn(AndroidSchedulers.mainThread()) @@ -178,7 +176,8 @@ class DonateToSignalViewModel( label = snapshot.badge!!.description, price = amount.amount, currencyCode = amount.currency.currencyCode, - level = snapshot.level.toLong() + level = snapshot.level.toLong(), + recipientId = Recipient.self().id ) } @@ -186,6 +185,7 @@ class DonateToSignalViewModel( return when (snapshot.donateToSignalType) { DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState) DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost() + DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.") } } @@ -348,18 +348,6 @@ class DonateToSignalViewModel( store.dispose() } - fun provideGatewayRequestForGooglePay(request: GatewayRequest) { - Log.d(TAG, "Provided with a gateway request.") - Preconditions.checkState(gatewayRequest == null) - gatewayRequest = request - } - - fun consumeGatewayRequestForGooglePay(): GatewayRequest? { - val request = gatewayRequest - gatewayRequest = null - return request - } - class Factory( private val startType: DonateToSignalType, private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt new file mode 100644 index 0000000000..2e1308b81c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -0,0 +1,193 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.fragment.findNavController +import androidx.navigation.navGraphViewModels +import com.google.android.gms.wallet.PaymentData +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.signal.donations.GooglePayApi +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.requireListener +import java.util.Currency + +/** + * Abstracts out some common UI-level interactions between gift flow and normal donate flow. + */ +class DonationCheckoutDelegate( + private val fragment: Fragment, + private val callback: Callback +) : DefaultLifecycleObserver { + + companion object { + private val TAG = Log.tag(DonationCheckoutDelegate::class.java) + } + + private lateinit var donationPaymentComponent: DonationPaymentComponent + private val disposables = LifecycleDisposable() + private val viewModel: DonationCheckoutViewModel by fragment.viewModels() + + private val stripePaymentViewModel: StripePaymentInProgressViewModel by fragment.navGraphViewModels( + R.id.donate_to_signal, + factoryProducer = { + donationPaymentComponent = fragment.requireListener() + StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository) + } + ) + + init { + fragment.viewLifecycleOwner.lifecycle.addObserver(this) + } + + override fun onCreate(owner: LifecycleOwner) { + disposables.bindTo(fragment.viewLifecycleOwner) + donationPaymentComponent = fragment.requireListener() + registerGooglePayCallback() + + fragment.setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle -> + val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!! + handleGatewaySelectionResponse(response) + } + + fragment.setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle -> + val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!! + handleDonationProcessorActionResult(result) + } + + fragment.setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle -> + val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!! + handleCreditCardResult(result) + } + } + + private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) { + when (gatewayResponse.gateway) { + GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse) + GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.") + GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse) + } + } + + private fun handleCreditCardResult(creditCardResult: CreditCardResult) { + Log.d(TAG, "Received credit card information from fragment.") + stripePaymentViewModel.provideCardData(creditCardResult.creditCardData) + callback.navigateToStripePaymentInProgress(creditCardResult.gatewayRequest) + } + + private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) { + when (result.status) { + DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result) + DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result) + } + + callback.onProcessorActionProcessed() + } + + private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) { + if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) { + Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show() + } else { + callback.onPaymentComplete(result.request) + } + } + + private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) { + if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) { + MaterialAlertDialogBuilder(fragment.requireContext()) + .setTitle(R.string.DonationsErrors__failed_to_cancel_subscription) + .setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection) + .setPositiveButton(android.R.string.ok) { _, _ -> + fragment.findNavController().popBackStack() + } + .show() + } else { + Log.w(TAG, "Stripe action failed: ${result.action}") + } + } + + private fun launchGooglePay(gatewayResponse: GatewayResponse) { + viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request) + donationPaymentComponent.stripeRepository.requestTokenFromGooglePay( + price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)), + label = gatewayResponse.request.label, + requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt() + ) + } + + private fun launchCreditCard(gatewayResponse: GatewayResponse) { + if (InAppDonations.isCreditCardAvailable()) { + callback.navigateToCreditCardForm(gatewayResponse.request) + } else { + error("Credit cards are not currently enabled.") + } + } + + private fun registerGooglePayCallback() { + disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy( + onNext = { paymentResult -> + viewModel.consumeGatewayRequestForGooglePay()?.let { + donationPaymentComponent.stripeRepository.onActivityResult( + paymentResult.requestCode, + paymentResult.resultCode, + paymentResult.data, + paymentResult.requestCode, + GooglePayRequestCallback(it) + ) + } + } + ) + } + + inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback { + override fun onSuccess(paymentData: PaymentData) { + Log.d(TAG, "Successfully retrieved payment data from Google Pay", true) + stripePaymentViewModel.providePaymentData(paymentData) + callback.navigateToStripePaymentInProgress(request) + } + + override fun onError(googlePayException: GooglePayApi.GooglePayException) { + Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true) + + val error = DonationError.getGooglePayRequestTokenError( + source = when (request.donateToSignalType) { + DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION + DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST + DonateToSignalType.GIFT -> DonationErrorSource.GIFT + }, + throwable = googlePayException + ) + + DonationError.routeDonationError(fragment.requireContext(), error) + } + + override fun onCancelled() { + Log.d(TAG, "Cancelled Google Pay.", true) + } + } + + interface Callback { + fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) + fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) + fun onPaymentComplete(gatewayRequest: GatewayRequest) + fun onProcessorActionProcessed() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt new file mode 100644 index 0000000000..d0858d6be0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import androidx.lifecycle.ViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.whispersystems.signalservice.api.util.Preconditions + +/** + * State holder for the checkout flow when utilizing Google Pay. + */ +class DonationCheckoutViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(DonationCheckoutViewModel::class.java) + } + + private var gatewayRequest: GatewayRequest? = null + + fun provideGatewayRequestForGooglePay(request: GatewayRequest) { + Log.d(TAG, "Provided with a gateway request.") + Preconditions.checkState(gatewayRequest == null) + gatewayRequest = request + } + + fun consumeGatewayRequestForGooglePay(): GatewayRequest? { + val request = gatewayRequest + gatewayRequest = null + return request + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt index 6dbc738efe..b150b3746a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt @@ -35,6 +35,9 @@ object DonationPillToggle { DonateToSignalType.MONTHLY -> { presentButtons(model, binding.monthly, binding.oneTime) } + DonateToSignalType.GIFT -> { + error("Unsupported donation type.") + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt index a43b803f39..79954eda64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt @@ -1,10 +1,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.recipients.RecipientId import java.math.BigDecimal import java.util.Currency @@ -15,7 +17,10 @@ data class GatewayRequest( val label: String, val price: BigDecimal, val currencyCode: String, - val level: Long + val level: Long, + val recipientId: RecipientId, + val additionalMessage: String? = null ) : Parcelable { + @IgnoredOnParcel val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index f6f0391952..a3f65bc5d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -62,6 +62,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() { when (args.request.donateToSignalType) { DonateToSignalType.MONTHLY -> presentMonthlyText() DonateToSignalType.ONE_TIME -> presentOneTimeText() + DonateToSignalType.GIFT -> presentGiftText() } space(66.dp) @@ -138,6 +139,25 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() { ) } + private fun DSLConfiguration.presentGiftText() { + noPadTextPref( + title = DSLSettingsText.from( + getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(resources, args.request.fiat)), + DSLSettingsText.CenterModifier, + DSLSettingsText.TitleLargeModifier + ) + ) + space(6.dp) + noPadTextPref( + title = DSLSettingsText.from( + R.string.GatewaySelectorBottomSheet__send_a_gift_badge, + DSLSettingsText.CenterModifier, + DSLSettingsText.BodyLargeModifier, + DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant)) + ) + ) + } + companion object { const val REQUEST_KEY = "payment_checkout_mode" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index 8053d10076..f46d760eb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -25,9 +25,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.rx.RxStore -import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels import org.whispersystems.signalservice.api.util.Preconditions class StripePaymentInProgressViewModel( @@ -74,6 +72,7 @@ class StripePaymentInProgressViewModel( val errorSource = when (request.donateToSignalType) { DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION + DonateToSignalType.GIFT -> DonationErrorSource.GIFT } val paymentSourceProvider: Single = resolvePaymentSourceProvider(errorSource) @@ -81,6 +80,7 @@ class StripePaymentInProgressViewModel( return when (request.donateToSignalType) { DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler) DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler) + DonateToSignalType.GIFT -> proceedOneTime(request, paymentSourceProvider, nextActionHandler) } } @@ -164,17 +164,23 @@ class StripePaymentInProgressViewModel( Log.w(TAG, "Beginning one-time payment pipeline...", true) val amount = request.fiat - val recipient = Recipient.self().id - val level = SubscriptionLevels.BOOST_LEVEL.toLong() - val continuePayment: Single = stripeRepository.continuePayment(amount, recipient, level) + val continuePayment: Single = stripeRepository.continuePayment(amount, request.recipientId, request.level) val intentAndSource: Single> = Single.zip(continuePayment, paymentSourceProvider, ::Pair) disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> - stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient) + stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId) .flatMap { nextActionHandler(it) } .flatMap { stripeRepository.getStatusAndPaymentMethodId(it) } - .flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption(amount, paymentIntent.intentId, recipient, null, level) } + .flatMapCompletable { + oneTimeDonationRepository.waitForOneTimeRedemption( + amount, + paymentIntent.intentId, + request.recipientId, + request.additionalMessage, + request.level + ) + } }.subscribeBy( onError = { throwable -> Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) diff --git a/app/src/main/res/layout/gift_flow_confirmation_content.xml b/app/src/main/res/layout/gift_flow_confirmation_content.xml index a1aac30e0f..c31e489b48 100644 --- a/app/src/main/res/layout/gift_flow_confirmation_content.xml +++ b/app/src/main/res/layout/gift_flow_confirmation_content.xml @@ -1,12 +1,12 @@ + tools:layout_height="match_parent" + tools:viewBindingIgnore="true"> @@ -33,7 +33,7 @@ android:gravity="center_vertical" android:text="@string/GiftFlowConfirmationFragment__one_time_donation" android:textAppearance="@style/Signal.Text.Body" - app:layout_constraintBottom_toTopOf="@id/google_pay_button" + app:layout_constraintBottom_toTopOf="@id/continue_button" app:layout_constraintEnd_toStartOf="@id/amount" app:layout_constraintStart_toStartOf="parent" /> @@ -55,20 +55,21 @@ android:layout_marginEnd="@dimen/dsl_settings_gutter" android:gravity="center_vertical" android:textAppearance="@style/Signal.Text.Title" - app:layout_constraintBottom_toTopOf="@id/google_pay_button" + app:layout_constraintBottom_toTopOf="@id/continue_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/description" tools:text="$10" /> - + android:insetTop="2dp" + android:insetBottom="2dp" + android:text="@string/DonateToSignalFragment__continue" + app:layout_constraintBottom_toBottomOf="parent" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/gift_flow.xml b/app/src/main/res/navigation/gift_flow.xml index 8e32803f6c..3eaab60b21 100644 --- a/app/src/main/res/navigation/gift_flow.xml +++ b/app/src/main/res/navigation/gift_flow.xml @@ -1,7 +1,8 @@ + android:label="GiftFlowConfirmationFragment" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec3670fcf1..f563fa7b27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5546,6 +5546,8 @@ Credit or debit card + + Send a gift badge Cancelling…