Add credit card support to badge gifting.

This commit is contained in:
Alex Hart 2022-11-16 12:10:26 -04:00
parent 1eb2f51398
commit a11c40e4fe
17 changed files with 464 additions and 331 deletions

View file

@ -4,10 +4,12 @@ import android.content.DialogInterface
import android.view.KeyEvent import android.view.KeyEvent
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject 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.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent 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.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource 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.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment 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.Debouncer
import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter 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. * 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, EmojiKeyboardPageFragment.Callback,
EmojiEventListener, EmojiEventListener,
EmojiSearchFragment.Callback { EmojiSearchFragment.Callback,
DonationCheckoutDelegate.Callback {
companion object { companion object {
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java) private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
@ -67,9 +73,9 @@ class GiftFlowConfirmationFragment :
private val lifecycleDisposable = LifecycleDisposable() private val lifecycleDisposable = LifecycleDisposable()
private var errorDialog: DialogInterface? = null private var errorDialog: DialogInterface? = null
private var donationCheckoutDelegate: DonationCheckoutDelegate? = null
private lateinit var processingDonationPaymentDialog: AlertDialog private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>() private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
@ -81,7 +87,7 @@ class GiftFlowConfirmationFragment :
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationPaymentComponent = requireListener() donationCheckoutDelegate = DonationCheckoutDelegate(this, this)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext()) processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog) .setView(R.layout.processing_payment_dialog)
@ -98,13 +104,29 @@ class GiftFlowConfirmationFragment :
emojiKeyboard.setFragmentManager(childFragmentManager) emojiKeyboard.setFragmentManager(childFragmentManager)
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button) val continueButton = requireView().findViewById<MaterialButton>(R.id.continue_button)
googlePayButton.setOnGooglePayClickListener { continueButton.setOnClickListener {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time)) 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<FrameLayout>(R.id.text_input) val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle) val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
val amountView = requireView().findViewById<TextView>(R.id.amount)
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher) textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
textInputViewHolder.onAttachedToWindow() textInputViewHolder.onAttachedToWindow()
@ -165,29 +187,17 @@ class GiftFlowConfirmationFragment :
} else { } else {
processingDonationPaymentDialog.dismiss() processingDonationPaymentDialog.dismiss()
} }
amountView.text = FiatMoneyUtil.format(resources, state.giftPrices[state.currency]!!, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
} }
lifecycleDisposable.bindTo(viewLifecycleOwner) lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT) .getErrorsForSource(DonationErrorSource.GIFT)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError -> .subscribe { donationError ->
onPaymentError(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() { override fun onDestroyView() {
@ -196,6 +206,7 @@ class GiftFlowConfirmationFragment :
processingDonationPaymentDialog.dismiss() processingDonationPaymentDialog.dismiss()
debouncer.clear() debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss() verifyingRecipientDonationPaymentDialog.dismiss()
donationCheckoutDelegate = null
} }
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration { 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?) { private fun onPaymentError(throwable: Throwable?) {
Log.w(TAG, "onPaymentError", throwable, true) Log.w(TAG, "onPaymentError", throwable, true)
@ -276,4 +277,24 @@ class GiftFlowConfirmationFragment :
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent)) 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
} }

View file

@ -9,18 +9,14 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText 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.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.SplashImage 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.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
/** /**
@ -33,11 +29,7 @@ class GiftFlowStartFragment : DSLSettingsFragment(
private val viewModel: GiftFlowViewModel by viewModels( private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }, ownerProducer = { requireActivity() },
factoryProducer = { factoryProducer = {
GiftFlowViewModel.Factory( GiftFlowViewModel.Factory(GiftFlowRepository())
GiftFlowRepository(),
requireListener<DonationPaymentComponent>().stripeRepository,
OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
)
} }
) )

View file

@ -1,14 +1,9 @@
package org.thoughtcrime.securesms.badges.gifts.flow package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider 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.Flowable
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
@ -16,19 +11,9 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney 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.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent 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.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver 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. * Maintains state as a user works their way through the gift flow.
*/ */
class GiftFlowViewModel( class GiftFlowViewModel(
private val giftFlowRepository: GiftFlowRepository, private val giftFlowRepository: GiftFlowRepository
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModel() { ) : ViewModel() {
private var giftToPurchase: Gift? = null
private val store = RxStore( private val store = RxStore(
GiftFlowState( GiftFlowState(
currency = SignalStore.donationsValues().getOneTimeCurrency() currency = SignalStore.donationsValues().getOneTimeCurrency()
@ -133,86 +114,6 @@ class GiftFlowViewModel(
return store.state.giftPrices.keys.map { it.currencyCode } 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<StripeIntentAccessor> = stripeRepository.continuePayment(gift.price, recipient, gift.level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = 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( private fun getLoadState(
oldState: GiftFlowState, oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null, giftPrices: Map<Currency, FiatMoney>? = null,
@ -250,16 +151,12 @@ class GiftFlowViewModel(
} }
class Factory( class Factory(
private val repository: GiftFlowRepository, private val repository: GiftFlowRepository
private val stripeRepository: StripeRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast( return modelClass.cast(
GiftFlowViewModel( GiftFlowViewModel(
repository, repository
stripeRepository,
oneTimeDonationRepository
) )
) as T ) as T
} }

View file

@ -62,7 +62,10 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
) )
noPadTextPref( 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()) space(DimensionUnit.DP.toPixels(37f).toInt())

View file

@ -8,23 +8,17 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.LottieAnimationView import com.airbnb.lottie.LottieAnimationView
import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.dp import org.signal.core.util.dp
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.BadgePreview import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout 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.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText 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.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.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.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource 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.Projection
import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Currency import java.util.Currency
/** /**
* Unified donation fragment which allows users to choose between monthly or one-time donations. * Unified donation fragment which allows users to choose between monthly or one-time donations.
*/ */
class DonateToSignalFragment : DSLSettingsFragment( class DonateToSignalFragment :
DSLSettingsFragment(
layoutId = R.layout.donate_to_signal_fragment layoutId = R.layout.donate_to_signal_fragment
) { ),
DonationCheckoutDelegate.Callback {
companion object { companion object {
private val TAG = Log.tag(DonateToSignalFragment::class.java) private val TAG = Log.tag(DonateToSignalFragment::class.java)
@ -98,18 +85,10 @@ class DonateToSignalFragment : DSLSettingsFragment(
DonateToSignalViewModel.Factory(args.startType) 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 disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind) private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind)
private lateinit var donationPaymentComponent: DonationPaymentComponent private var donationCheckoutDelegate: DonationCheckoutDelegate? = null
private val supportTechSummary: CharSequence by lazy { private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging))) 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) { override fun bindAdapter(adapter: MappingAdapter) {
donationPaymentComponent = requireListener() donationCheckoutDelegate = DonationCheckoutDelegate(this, this)
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)
}
val recyclerView = this.recyclerView!! val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS 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 { private fun getConfiguration(state: DonateToSignalState): DSLConfiguration {
return configure { return configure {
space(36.dp) 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) { private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) { if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true) 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 navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
override fun onSuccess(paymentData: PaymentData) { findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
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 onError(googlePayException: GooglePayApi.GooglePayException) { override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true) findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
val error = DonationError.getGooglePayRequestTokenError(
source = when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
},
throwable = googlePayException
)
DonationError.routeDonationError(requireContext(), error)
} }
override fun onCancelled() { override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
Log.d(TAG, "Cancelled Google Pay.", true) findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
} }
override fun onProcessorActionProcessed() {
viewModel.refreshActiveSubscription()
} }
} }

View file

@ -21,48 +21,56 @@ data class DonateToSignalState(
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val badge: Badge? val badge: Badge?
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val canSetCurrency: Boolean val canSetCurrency: Boolean
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled DonateToSignalType.ONE_TIME -> areFieldsEnabled
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val selectedCurrency: Currency val selectedCurrency: Currency
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val selectableCurrencyCodes: List<String> val selectableCurrencyCodes: List<String>
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes
DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val level: Int val level: Int
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> 1 DonateToSignalType.ONE_TIME -> 1
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val canContinue: Boolean val canContinue: Boolean
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable() DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable() DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
val canUpdate: Boolean val canUpdate: Boolean
get() = when (donateToSignalType) { get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false DonateToSignalType.ONE_TIME -> false
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
DonateToSignalType.GIFT -> error("This flow does not support gifts")
} }
data class OneTimeDonationState( data class OneTimeDonationState(

View file

@ -6,5 +6,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
enum class DonateToSignalType(val requestCode: Short) : Parcelable { enum class DonateToSignalType(val requestCode: Short) : Parcelable {
ONE_TIME(16141), ONE_TIME(16141),
MONTHLY(16142); MONTHLY(16142),
GIFT(16143)
} }

View file

@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.manage.Su
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.subscription.Subscription 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.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.Preconditions
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
@ -57,8 +57,6 @@ class DonateToSignalViewModel(
private val _actions = PublishSubject.create<DonateToSignalAction>() private val _actions = PublishSubject.create<DonateToSignalAction>()
private val _activeSubscription = PublishSubject.create<ActiveSubscription>() private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
private var gatewayRequest: GatewayRequest? = null
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread()) val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
@ -178,7 +176,8 @@ class DonateToSignalViewModel(
label = snapshot.badge!!.description, label = snapshot.badge!!.description,
price = amount.amount, price = amount.amount,
currencyCode = amount.currency.currencyCode, 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) { return when (snapshot.donateToSignalType) {
DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState) DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState)
DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost() DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost()
DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.")
} }
} }
@ -348,18 +348,6 @@ class DonateToSignalViewModel(
store.dispose() 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( class Factory(
private val startType: DonateToSignalType, private val startType: DonateToSignalType,
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()), private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -35,6 +35,9 @@ object DonationPillToggle {
DonateToSignalType.MONTHLY -> { DonateToSignalType.MONTHLY -> {
presentButtons(model, binding.monthly, binding.oneTime) presentButtons(model, binding.monthly, binding.oneTime)
} }
DonateToSignalType.GIFT -> {
error("Unsupported donation type.")
}
} }
} }

View file

@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.recipients.RecipientId
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Currency import java.util.Currency
@ -15,7 +17,10 @@ data class GatewayRequest(
val label: String, val label: String,
val price: BigDecimal, val price: BigDecimal,
val currencyCode: String, val currencyCode: String,
val level: Long val level: Long,
val recipientId: RecipientId,
val additionalMessage: String? = null
) : Parcelable { ) : Parcelable {
@IgnoredOnParcel
val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode)) val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode))
} }

View file

@ -62,6 +62,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
when (args.request.donateToSignalType) { when (args.request.donateToSignalType) {
DonateToSignalType.MONTHLY -> presentMonthlyText() DonateToSignalType.MONTHLY -> presentMonthlyText()
DonateToSignalType.ONE_TIME -> presentOneTimeText() DonateToSignalType.ONE_TIME -> presentOneTimeText()
DonateToSignalType.GIFT -> presentGiftText()
} }
space(66.dp) 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 { companion object {
const val REQUEST_KEY = "payment_checkout_mode" const val REQUEST_KEY = "payment_checkout_mode"
} }

View file

@ -25,9 +25,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.rx.RxStore import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.api.util.Preconditions import org.whispersystems.signalservice.api.util.Preconditions
class StripePaymentInProgressViewModel( class StripePaymentInProgressViewModel(
@ -74,6 +72,7 @@ class StripePaymentInProgressViewModel(
val errorSource = when (request.donateToSignalType) { val errorSource = when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
} }
val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource) val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource)
@ -81,6 +80,7 @@ class StripePaymentInProgressViewModel(
return when (request.donateToSignalType) { return when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler) DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.ONE_TIME -> proceedOneTime(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) Log.w(TAG, "Beginning one-time payment pipeline...", true)
val amount = request.fiat val amount = request.fiat
val recipient = Recipient.self().id
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, recipient, level) val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, request.recipientId, request.level)
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair) val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient) stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
.flatMap { nextActionHandler(it) } .flatMap { nextActionHandler(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(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( }.subscribeBy(
onError = { throwable -> onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)

View file

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
tools:layout_height="match_parent"> tools:layout_height="match_parent"
tools:viewBindingIgnore="true">
<include layout="@layout/dsl_settings_toolbar" /> <include layout="@layout/dsl_settings_toolbar" />
@ -33,7 +33,7 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:text="@string/GiftFlowConfirmationFragment__one_time_donation" android:text="@string/GiftFlowConfirmationFragment__one_time_donation"
android:textAppearance="@style/Signal.Text.Body" 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_constraintEnd_toStartOf="@id/amount"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
@ -55,20 +55,21 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter" android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center_vertical" android:gravity="center_vertical"
android:textAppearance="@style/Signal.Text.Title" 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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/description" app:layout_constraintStart_toEndOf="@id/description"
tools:text="$10" /> tools:text="$10" />
<org.thoughtcrime.securesms.badges.gifts.flow.GooglePayButton <com.google.android.material.button.MaterialButton
android:id="@+id/google_pay_button" android:id="@+id/continue_button"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="48dp"
android:layout_marginStart="@dimen/dsl_settings_gutter" android:layout_marginHorizontal="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:insetTop="2dp"
app:layout_constraintEnd_toEndOf="parent" android:insetBottom="2dp"
app:layout_constraintStart_toStartOf="parent" /> android:text="@string/DonateToSignalFragment__continue"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android" <navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/gift_flow" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/donate_to_signal"
app:startDestination="@id/giftFlowStartFragment"> app:startDestination="@id/giftFlowStartFragment">
<fragment <fragment
@ -48,5 +49,87 @@
<fragment <fragment
android:id="@+id/giftFlowConfirmationFragment" android:id="@+id/giftFlowConfirmationFragment"
android:name="org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowConfirmationFragment" android:name="org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowConfirmationFragment"
android:label="GiftFlowConfirmationFragment" /> android:label="GiftFlowConfirmationFragment" >
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_creditCardFragment"
app:destination="@id/creditCardFragment" />
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_stripePaymentInProgressFragment"
app:destination="@id/stripePaymentInProgressFragment" />
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_gatewaySelectorBottomSheet"
app:destination="@id/gatewaySelectorBottomSheet" />
</fragment>
<dialog
android:id="@+id/gatewaySelectorBottomSheet"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet"
android:label="gateway_selector_bottom_sheet"
tools:layout="@layout/dsl_settings_bottom_sheet">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</dialog>
<dialog
android:id="@+id/stripePaymentInProgressFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment"
android:label="stripe_payment_in_progress_fragment"
tools:layout="@layout/stripe_payment_in_progress_fragment">
<argument
android:name="action"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction"
app:nullable="false" />
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_stripePaymentInProgressFragment_to_stripe3dsDialogFragment"
app:destination="@id/stripe3dsDialogFragment" />
</dialog>
<fragment
android:id="@+id/creditCardFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment"
android:label="credit_card_fragment"
tools:layout="@layout/credit_card_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_creditCardFragment_to_yourInformationIsPrivateBottomSheet"
app:destination="@id/yourInformationIsPrivateBottomSheet" />
</fragment>
<dialog
android:id="@+id/stripe3dsDialogFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment"
android:label="stripe_3ds_dialog_fragment"
tools:layout="@layout/stripe_3ds_dialog_fragment">
<argument
android:name="uri"
app:argType="android.net.Uri"
app:nullable="false" />
<argument
android:name="return_uri"
app:argType="android.net.Uri"
app:nullable="false" />
</dialog>
<dialog
android:id="@+id/yourInformationIsPrivateBottomSheet"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.YourInformationIsPrivateBottomSheet"
android:label="your_information_is_private_bottom_sheet"
tools:layout="@layout/dsl_settings_bottom_sheet" />
</navigation> </navigation>

View file

@ -5546,6 +5546,8 @@
</plurals> </plurals>
<!-- Button label for paying with a credit card --> <!-- Button label for paying with a credit card -->
<string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string> <string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string>
<!-- Sheet summary when giving a gift -->
<string name="GatewaySelectorBottomSheet__send_a_gift_badge">Send a gift badge</string>
<!-- StripePaymentInProgressFragment --> <!-- StripePaymentInProgressFragment -->
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string> <string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>