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 d756117be..b3aaad27c 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 @@ -5,8 +5,10 @@ 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,6 +18,7 @@ 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.thoughtcrime.securesms.badges.gifts.Gifts import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent @@ -165,7 +168,14 @@ class GiftFlowViewModel( store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) } - donationPaymentRepository.continuePayment(gift.price, GooglePayPaymentSource(paymentData), recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy( + val continuePayment: Single = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level) + val intentAndSource: Single> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair) + + disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> + donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient) + .flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts. + .andThen(donationPaymentRepository.waitForOneTimeRedemption(gift.price, paymentIntent, recipient, store.state.additionalMessage?.toString(), gift.level)) + }.subscribeBy( onError = this@GiftFlowViewModel::onPaymentFlowError, onComplete = { store.update { it.copy(stage = GiftFlowState.Stage.READY) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index a104fd066..cec996675 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -127,17 +127,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet /** * @param price The amount to charce the local user - * @param paymentData PaymentData from Google Pay that describes the payment method * @param badgeRecipient Who will be getting the badge - * @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self) */ fun continuePayment( price: FiatMoney, - paymentSource: StripeApi.PaymentSource, badgeRecipient: RecipientId, - additionalMessage: String?, - badgeLevel: Long - ): Completable { + badgeLevel: Long, + ): Single { Log.d(TAG, "Creating payment intent for $price...", true) return stripeApi.createPaymentIntent(price, badgeLevel) @@ -150,28 +146,26 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet Single.error(DonationError.getPaymentSetupError(errorSource, it)) } } - .flatMapCompletable { result -> + .flatMap { result -> val recipient = Recipient.resolved(badgeRecipient) val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT Log.d(TAG, "Created payment intent for $price.", true) when (result) { - is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource)) - is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource)) - is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource)) - is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentSource, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel) + is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource)) + is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource)) + is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource)) + is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent) } }.subscribeOn(Schedulers.io()) } - fun continueSubscriptionSetup(paymentSource: StripeApi.PaymentSource): Completable { + fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single { Log.d(TAG, "Continuing subscription setup...", true) return stripeApi.createSetupIntent() - .flatMapCompletable { result -> + .flatMap { result -> Log.d(TAG, "Retrieved SetupIntent, confirming...", true) - stripeApi.confirmSetupIntent(paymentSource, result.setupIntent).doOnComplete { - Log.d(TAG, "Confirmed SetupIntent...", true) - } + stripeApi.confirmSetupIntent(paymentSource, result.setupIntent) } } @@ -211,14 +205,30 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } - private fun confirmPayment(price: FiatMoney, paymentSource: StripeApi.PaymentSource, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable { + fun confirmPayment( + paymentSource: StripeApi.PaymentSource, + paymentIntent: StripeApi.PaymentIntent, + badgeRecipient: RecipientId + ): Single { val isBoost = badgeRecipient == Recipient.self().id val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT Log.d(TAG, "Confirming payment intent...", true) - val confirmPayment = stripeApi.confirmPaymentIntent(paymentSource, paymentIntent).onErrorResumeNext { - Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it)) - } + return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent) + .onErrorResumeNext { + Single.error(DonationError.getPaymentSetupError(donationErrorSource, it)) + } + } + + fun waitForOneTimeRedemption( + price: FiatMoney, + paymentIntent: StripeApi.PaymentIntent, + badgeRecipient: RecipientId, + additionalMessage: String?, + badgeLevel: Long, + ): Completable { + val isBoost = badgeRecipient == Recipient.self().id + val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT val waitOnRedemption = Completable.create { val donationReceiptRecord = if (isBoost) { @@ -273,7 +283,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } - return confirmPayment.andThen(waitOnRedemption) + return waitOnRedemption } fun setSubscriptionLevel(subscriptionLevel: String): Completable { @@ -405,11 +415,12 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } - override fun setDefaultPaymentMethod(paymentMethodId: String): Completable { - Log.d(TAG, "Setting default payment method via Signal service...") + fun setDefaultPaymentMethod(paymentMethodId: String): Completable { return Single.fromCallable { + Log.d(TAG, "Getting the subscriber...") SignalStore.donationsValues().requireSubscriber() }.flatMap { + Log.d(TAG, "Setting default payment method via Signal service...") Single.fromCallable { ApplicationDependencies .getDonationsService() @@ -420,6 +431,16 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } + fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single { + Log.d(TAG, "Creating credit card payment source via Stripe api...") + return stripeApi.createPaymentSourceFromCardData(cardData).map { + when (it) { + is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason) + is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource + } + } + } + companion object { private val TAG = Log.tag(DonationPaymentRepository::class.java) } 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 544e5a98c..688ce743a 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 @@ -34,6 +34,8 @@ 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.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 @@ -143,6 +145,11 @@ class DonateToSignalFragment : DSLSettingsFragment( handleStripeActionResult(result) } + setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle -> + val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!! + handleCreditCardResult(result) + } + val recyclerView = this.recyclerView!! recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS @@ -400,6 +407,12 @@ class DonateToSignalFragment : DSLSettingsFragment( } } + private fun handleCreditCardResult(creditCardResult: CreditCardResult) { + Log.d(TAG, "Received credit card information from fragment.") + stripePaymentViewModel.provideCardData(creditCardResult.creditCardData) + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest)) + } + private fun handleStripeActionResult(result: StripeActionResult) { when (result.status) { StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt new file mode 100644 index 000000000..9998e573a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.os.Bundle +import android.view.View +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.navArgs +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding +import org.thoughtcrime.securesms.util.visible + +/** + * Full-screen dialog for displaying Stripe 3DS confirmation. + */ +class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragment) { + + companion object { + const val REQUEST_KEY = "stripe_3ds_dialog_fragment" + private const val STRIPE_3DS_COMPLETE = "https://hooks.stripe.com/3d_secure/complete/tdsrc_complete" + } + + val binding by ViewBinderDelegate(Stripe3dsDialogFragmentBinding::bind) { + it.webView.clearCache(true) + it.webView.clearHistory() + } + + val args: Stripe3DSDialogFragmentArgs by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.webView.webViewClient = Stripe3DSWebClient() + binding.webView.settings.javaScriptEnabled = true + binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE + binding.webView.loadUrl(args.uri.toString()) + } + + override fun onDismiss(dialog: DialogInterface) { + setFragmentResult(REQUEST_KEY, Bundle()) + } + + private inner class Stripe3DSWebClient : WebViewClient() { + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + binding.progress.visible = true + } + + override fun onPageCommitVisible(view: WebView?, url: String?) { + binding.progress.visible = false + } + + override fun onPageFinished(view: WebView?, url: String?) { + if (url == STRIPE_3DS_COMPLETE) { + dismissAllowingStateLoss() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt index 4e82ea716..27e727d5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card +import org.signal.donations.StripeApi + data class CreditCardFormState( val focusedField: FocusedField = FocusedField.NONE, val number: String = "", @@ -12,4 +14,13 @@ data class CreditCardFormState( EXPIRATION, CODE } + + fun toCardData(): StripeApi.CardData { + return StripeApi.CardData( + number, + expiration.month.toInt(), + expiration.year.toInt(), + code.toInt() + ) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt index 98ac9ce00..82b16f634 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -4,13 +4,17 @@ import android.content.Context import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.core.os.bundleOf import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding +import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.ViewUtil @@ -22,6 +26,9 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { private val lifecycleDisposable = LifecycleDisposable() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.title.text = getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat)) + binding.cardNumber.addTextChangedListener(afterTextChanged = { viewModel.onNumberChanged(it?.toString() ?: "") }) @@ -46,10 +53,27 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { viewModel.onExpirationFocusChanged(hasFocus) } + binding.continueButton.setOnClickListener { + findNavController().popBackStack() + + val resultBundle = bundleOf( + REQUEST_KEY to CreditCardResult( + args.request, + viewModel.getCardData() + ) + ) + + setFragmentResult(REQUEST_KEY, resultBundle) + } + + binding.toolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + lifecycleDisposable.bindTo(viewLifecycleOwner) lifecycleDisposable += viewModel.state.subscribe { // TODO [alex] -- type - // TODO [alex] -- all fields valid + presentContinue(it) presentCardNumberWrapper(it.numberValidity) presentCardExpiryWrapper(it.expirationValidity) presentCardCodeWrapper(it.codeValidity) @@ -67,6 +91,10 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { } } + private fun presentContinue(state: CreditCardValidationState) { + binding.continueButton.isEnabled = state.isValid + } + private fun presentCardNumberWrapper(validity: CreditCardNumberValidator.Validity) { val errorState = when (validity) { CreditCardNumberValidator.Validity.INVALID -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_card_number) @@ -116,6 +144,8 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { } companion object { + val REQUEST_KEY = "card.data" + private val NO_ERROR = ErrorState(false, -1) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt new file mode 100644 index 000000000..c9d0153ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.signal.donations.StripeApi +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest + +/** + * Encapsulates data returned from the credit card form that can be used + * for a credit card based donation payment. + */ +@Parcelize +data class CreditCardResult( + val gatewayRequest: GatewayRequest, + val creditCardData: StripeApi.CardData +) : Parcelable diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt index 485972dd2..e575ca912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt @@ -5,4 +5,9 @@ data class CreditCardValidationState( val numberValidity: CreditCardNumberValidator.Validity, val expirationValidity: CreditCardExpirationValidator.Validity, val codeValidity: CreditCardCodeValidator.Validity -) +) { + val isValid: Boolean = + numberValidity == CreditCardNumberValidator.Validity.FULLY_VALID && + expirationValidity == CreditCardExpirationValidator.Validity.FULLY_VALID && + codeValidity == CreditCardCodeValidator.Validity.FULLY_VALID +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt index 7606420be..16db84b6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt @@ -6,6 +6,7 @@ import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.processors.BehaviorProcessor +import org.signal.donations.StripeApi import org.thoughtcrime.securesms.util.rx.RxStore import java.util.Calendar @@ -76,6 +77,10 @@ class CreditCardViewModel : ViewModel() { updateFocus(CreditCardFormState.FocusedField.CODE, isFocused) } + fun getCardData(): StripeApi.CardData { + return formStore.state.toCardData() + } + private fun updateFocus( newFocusedField: CreditCardFormState.FocusedField, isFocused: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index 86d3395a1..b594de57c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -7,21 +7,31 @@ import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.setFragmentResult import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.navigation.navGraphViewModels +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.logging.Log +import org.signal.donations.StripeApi import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Stripe3DSDialogFragment import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) { companion object { + private val TAG = Log.tag(StripePaymentInProgressFragment::class.java) + const val REQUEST_KEY = "REQUEST_KEY" } @@ -44,15 +54,11 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - disposables.bindTo(viewLifecycleOwner) - disposables += viewModel.state.subscribeBy { stage -> - presentUiState(stage) - } - if (savedInstanceState == null) { + viewModel.onBeginNewAction() when (args.action) { StripeAction.PROCESS_NEW_DONATION -> { - viewModel.processNewDonation(args.request) + viewModel.processNewDonation(args.request, this::handleSecure3dsAction) } StripeAction.UPDATE_SUBSCRIPTION -> { viewModel.updateSubscription(args.request) @@ -62,6 +68,11 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i } } } + + disposables.bindTo(viewLifecycleOwner) + disposables += viewModel.state.subscribeBy { stage -> + presentUiState(stage) + } } private fun presentUiState(stage: StripeStage) { @@ -69,6 +80,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment) StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment) StripeStage.FAILED -> { + viewModel.onEndAction() findNavController().popBackStack() setFragmentResult( REQUEST_KEY, @@ -82,6 +94,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i ) } StripeStage.COMPLETE -> { + viewModel.onEndAction() findNavController().popBackStack() setFragmentResult( REQUEST_KEY, @@ -97,4 +110,29 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling) } } + + private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Completable { + return when (secure3dsAction) { + is StripeApi.Secure3DSAction.NotNeeded -> { + Log.d(TAG, "No 3DS action required.") + Completable.complete() + } + is StripeApi.Secure3DSAction.ConfirmRequired -> { + Log.d(TAG, "3DS action required. Displaying dialog...") + Completable.create { emitter -> + val listener = FragmentResultListener { _, _ -> + emitter.onComplete() + } + + parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener) + + findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri)) + + emitter.setCancellable { + parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) + } + }.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io()) + } + } + } } 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 d25976966..bd76a5540 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 @@ -12,6 +12,7 @@ import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.logging.Log import org.signal.donations.GooglePayPaymentSource +import org.signal.donations.StripeApi import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest @@ -38,43 +39,95 @@ class StripePaymentInProgressViewModel( private val disposables = CompositeDisposable() private var paymentData: PaymentData? = null + private var cardData: StripeApi.CardData? = null override fun onCleared() { disposables.clear() store.dispose() + clearPaymentInformation() } - fun processNewDonation(request: GatewayRequest) { - val paymentData = this.paymentData ?: error("Cannot process new donation without payment data") - this.paymentData = null + fun onBeginNewAction() { + Preconditions.checkState(!store.state.isInProgress) - Preconditions.checkState(store.state != StripeStage.PAYMENT_PIPELINE) - Log.d(TAG, "Proceeding with donation...") + Log.d(TAG, "Beginning a new action. Ensuring cleared state.", true) + disposables.clear() + } + + fun onEndAction() { + Preconditions.checkState(store.state.isTerminal) + + Log.d(TAG, "Ending current state. Clearing state and setting stage to INIT", true) + store.update { StripeStage.INIT } + disposables.clear() + } + + fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { + Log.d(TAG, "Proceeding with donation...", true) + + val errorSource = when (request.donateToSignalType) { + DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST + DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION + } + + val paymentSourceProvider: Single = resolvePaymentSourceProvider(errorSource) return when (request.donateToSignalType) { - DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentData) - DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentData) + DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler) + DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler) } } + private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): Single { + val paymentData = this.paymentData + val cardData = this.cardData + + return when { + paymentData == null && cardData == null -> error("No payment provider available.") + paymentData != null && cardData != null -> error("Too many providers available") + paymentData != null -> Single.just(GooglePayPaymentSource(paymentData)) + cardData != null -> donationPaymentRepository.createCreditCardPaymentSource(errorSource, cardData) + else -> error("This should never happen.") + }.doAfterTerminate { clearPaymentInformation() } + } + fun providePaymentData(paymentData: PaymentData) { + requireNoPaymentInformation() this.paymentData = paymentData } - private fun proceedMonthly(request: GatewayRequest, paymentData: PaymentData) { - val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId() - val continueSetup = donationPaymentRepository.continueSubscriptionSetup(GooglePayPaymentSource(paymentData)) - val setLevel = donationPaymentRepository.setSubscriptionLevel(request.level.toString()) + fun provideCardData(cardData: StripeApi.CardData) { + requireNoPaymentInformation() + this.cardData = cardData + } + + private fun requireNoPaymentInformation() { + require(paymentData == null) + require(cardData == null) + } + + private fun clearPaymentInformation() { + Log.d(TAG, "Cleared payment information.", true) + paymentData = null + cardData = null + } + + private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { + val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId() + val createAndConfirmSetupIntent: Single = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) } + val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString()) Log.d(TAG, "Starting subscription payment pipeline...", true) store.update { StripeStage.PAYMENT_PIPELINE } - val setup = ensureSubscriberId + val setup: Completable = ensureSubscriberId .andThen(cancelActiveSubscriptionIfNecessary()) - .andThen(continueSetup) + .andThen(createAndConfirmSetupIntent) + .flatMap { secure3DSAction -> nextActionHandler(secure3DSAction).andThen(Single.just(secure3DSAction.paymentMethodId!!)) } + .flatMapCompletable { donationPaymentRepository.setDefaultPaymentMethod(it) } .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } - setup.andThen(setLevel).subscribeBy( + disposables += setup.andThen(setLevel).subscribeBy( onError = { throwable -> Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true) store.update { StripeStage.FAILED } @@ -107,10 +160,25 @@ class StripePaymentInProgressViewModel( } } - private fun proceedOneTime(request: GatewayRequest, paymentData: PaymentData) { + private fun proceedOneTime( + request: GatewayRequest, + paymentSourceProvider: Single, + nextActionHandler: (StripeApi.Secure3DSAction) -> Completable + ) { Log.w(TAG, "Beginning one-time payment pipeline...", true) - donationPaymentRepository.continuePayment(request.fiat, GooglePayPaymentSource(paymentData), Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy( + val amount = request.fiat + val recipient = Recipient.self().id + val level = SubscriptionLevels.BOOST_LEVEL.toLong() + + val continuePayment: Single = donationPaymentRepository.continuePayment(amount, recipient, level) + val intentAndSource: Single> = Single.zip(continuePayment, paymentSourceProvider, ::Pair) + + disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> + donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient) + .flatMapCompletable { nextActionHandler(it) } + .andThen(donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level)) + }.subscribeBy( onError = { throwable -> Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) store.update { StripeStage.FAILED } @@ -130,6 +198,8 @@ class StripePaymentInProgressViewModel( } fun cancelSubscription() { + Log.d(TAG, "Beginning cancellation...", true) + store.update { StripeStage.CANCELLING } disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy( onComplete = { @@ -147,8 +217,10 @@ class StripePaymentInProgressViewModel( } fun updateSubscription(request: GatewayRequest) { + Log.d(TAG, "Beginning subscription update...", true) + store.update { StripeStage.PAYMENT_PIPELINE } - cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString())) + disposables += cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString())) .subscribeBy( onComplete = { Log.w(TAG, "Completed subscription update", true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt index 57c1d72cf..167748add 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt @@ -5,5 +5,8 @@ enum class StripeStage { PAYMENT_PIPELINE, CANCELLING, FAILED, - COMPLETE + COMPLETE; + + val isInProgress: Boolean get() = this == PAYMENT_PIPELINE || this == CANCELLING + val isTerminal: Boolean get() = this == FAILED || this == COMPLETE } diff --git a/app/src/main/res/layout/credit_card_fragment.xml b/app/src/main/res/layout/credit_card_fragment.xml index 9f909e96b..c118d1773 100644 --- a/app/src/main/res/layout/credit_card_fragment.xml +++ b/app/src/main/res/layout/credit_card_fragment.xml @@ -114,4 +114,28 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stripe_3ds_dialog_fragment.xml b/app/src/main/res/layout/stripe_3ds_dialog_fragment.xml new file mode 100644 index 000000000..d80f9117b --- /dev/null +++ b/app/src/main/res/layout/stripe_3ds_dialog_fragment.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index f04336314..39df265b2 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -85,6 +85,9 @@ android:name="request" app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest" app:nullable="false" /> + + android:defaultValue="false" + app:argType="boolean" /> + + + + + \ 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 3fc3bb257..725eb108d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,6 +152,8 @@ Year required Invalid year + + Continue Block and leave %1$s? diff --git a/donations/lib/build.gradle b/donations/lib/build.gradle index ee1ceced2..b96c84e2b 100644 --- a/donations/lib/build.gradle +++ b/donations/lib/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'kotlin-android' + id 'kotlin-parcelize' } android { diff --git a/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt new file mode 100644 index 000000000..8311f5c9c --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt @@ -0,0 +1,14 @@ +package org.signal.donations + +import org.json.JSONObject + +/** + * Stripe payment source based off a manually entered credit card. + */ +class CreditCardPaymentSource( + private val payload: JSONObject +) : StripeApi.PaymentSource { + override fun parameterize(): JSONObject = payload + override fun getTokenId(): String = parameterize().getString("id") + override fun email(): String? = null +} \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt index 356290fe2..e4ab2af28 100644 --- a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt @@ -10,6 +10,11 @@ class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.P return paymentMethodJsonData.getJSONObject("tokenizationData") } + override fun getTokenId(): String { + val serializedToken = parameterize().getString("token").replace("\n", "") + return JSONObject(serializedToken).getString("id") + } + override fun email(): String? { val jsonData = JSONObject(paymentData.toJson()) return if (jsonData.has("email")) { diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 9acb73171..bab362398 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -1,8 +1,12 @@ package org.signal.donations +import android.net.Uri +import android.os.Parcelable +import androidx.annotation.WorkerThread import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.parcelize.Parcelize import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request @@ -23,6 +27,11 @@ class StripeApi( companion object { private val TAG = Log.tag(StripeApi::class.java) + + private val CARD_NUMBER_KEY = "card[number]" + private val CARD_MONTH_KEY = "card[exp_month]" + private val CARD_YEAR_KEY = "card[exp_year]" + private val CARD_CVC_KEY = "card[cvc]" } sealed class CreatePaymentIntentResult { @@ -34,6 +43,11 @@ class StripeApi( data class CreateSetupIntentResult(val setupIntent: SetupIntent) + sealed class CreatePaymentSourceFromCardDataResult { + data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult() + data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult() + } + fun createSetupIntent(): Single { return setupIntentHelper .fetchSetupIntent() @@ -41,18 +55,21 @@ class StripeApi( .subscribeOn(Schedulers.io()) } - fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Completable = Single.fromCallable { - val paymentMethodId = createPaymentMethodAndParseId(paymentSource) + fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single { + return Single.fromCallable { + val paymentMethodId = createPaymentMethodAndParseId(paymentSource) - val parameters = mapOf( - "client_secret" to setupIntent.clientSecret, - "payment_method" to paymentMethodId - ) + val parameters = mapOf( + "client_secret" to setupIntent.clientSecret, + "payment_method" to paymentMethodId + ) - postForm("setup_intents/${setupIntent.id}/confirm", parameters) - paymentMethodId - }.flatMapCompletable { - setupIntentHelper.setDefaultPaymentMethod(it) + val nextAction = postForm("setup_intents/${setupIntent.id}/confirm", parameters).use { response -> + getNextAction(response) + } + + Secure3DSAction.from(nextAction, paymentMethodId) + } } fun createPaymentIntent(price: FiatMoney, level: Long): Single { @@ -70,16 +87,72 @@ class StripeApi( }.subscribeOn(Schedulers.io()) } - fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction { - val paymentMethodId = createPaymentMethodAndParseId(paymentSource) + /** + * Confirm a PaymentIntent + * + * This method will create a PaymentMethod with the given PaymentSource and then confirm the + * PaymentIntent. + * + * @return A Secure3DSAction + */ + fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Single { + return Single.fromCallable { + val paymentMethodId = createPaymentMethodAndParseId(paymentSource) - val parameters = mutableMapOf( - "client_secret" to paymentIntent.clientSecret, - "payment_method" to paymentMethodId + val parameters = mutableMapOf( + "client_secret" to paymentIntent.clientSecret, + "payment_method" to paymentMethodId + ) + + val nextAction = postForm("payment_intents/${paymentIntent.id}/confirm", parameters).use { response -> + getNextAction(response) + } + + Secure3DSAction.from(nextAction) + }.subscribeOn(Schedulers.io()) + } + + private fun getNextAction(response: Response): Uri { + val responseBody = response.body()?.string() + val bodyJson = responseBody?.let { JSONObject(it) } + return if (bodyJson?.has("next_action") == true && !bodyJson.isNull("next_action")) { + val nextAction = bodyJson.getJSONObject("next_action") + if (BuildConfig.DEBUG) { + Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction") + } + + Uri.parse(nextAction.getJSONObject("use_stripe_sdk").getString("stripe_js")) + } else { + Uri.EMPTY + } + } + + fun createPaymentSourceFromCardData(cardData: CardData): Single { + return Single.fromCallable { + CreatePaymentSourceFromCardDataResult.Success(createPaymentSourceFromCardDataSync(cardData)) + }.onErrorReturn { + CreatePaymentSourceFromCardDataResult.Failure(it) + }.subscribeOn(Schedulers.io()) + } + + @WorkerThread + private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource { + val parameters: Map = mutableMapOf( + CARD_NUMBER_KEY to cardData.number, + CARD_MONTH_KEY to cardData.month.toString(), + CARD_YEAR_KEY to cardData.year.toString(), + CARD_CVC_KEY to cardData.cvc.toString() ) - postForm("payment_intents/${paymentIntent.id}/confirm", parameters) - }.subscribeOn(Schedulers.io()) + postForm("tokens", parameters).use { response -> + val body = response.body() + if (body != null) { + return CreditCardPaymentSource(JSONObject(body.string())) + } else { + throw StripeError.FailedToCreatePaymentSourceFromCardData + } + } + } private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String { return createPaymentMethod(paymentSource).use { response -> @@ -94,9 +167,9 @@ class StripeApi( } private fun createPaymentMethod(paymentSource: PaymentSource): Response { - val tokenizationData = paymentSource.parameterize() + val tokenId = paymentSource.getTokenId() val parameters = mutableMapOf( - "card[token]" to JSONObject((tokenizationData.get("token") as String).replace("\n", "")).getString("id"), + "card[token]" to tokenId, "type" to "card", ) @@ -366,9 +439,16 @@ class StripeApi( interface SetupIntentHelper { fun fetchSetupIntent(): Single - fun setDefaultPaymentMethod(paymentMethodId: String): Completable } + @Parcelize + data class CardData( + val number: String, + val month: Int, + val year: Int, + val cvc: Int + ) : Parcelable + data class PaymentIntent( val id: String, val clientSecret: String @@ -381,6 +461,24 @@ class StripeApi( interface PaymentSource { fun parameterize(): JSONObject + fun getTokenId(): String fun email(): String? } + + sealed interface Secure3DSAction { + data class ConfirmRequired(val uri: Uri, override val paymentMethodId: String?) : Secure3DSAction + data class NotNeeded(override val paymentMethodId: String?): Secure3DSAction + + val paymentMethodId: String? + + companion object { + fun from(uri: Uri, paymentMethodId: String? = null): Secure3DSAction { + return if (uri == Uri.EMPTY) { + NotNeeded(paymentMethodId) + } else { + ConfirmRequired(uri, paymentMethodId) + } + } + } + } } \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/StripeError.kt b/donations/lib/src/main/java/org/signal/donations/StripeError.kt index 3223542cd..ef6f17b1a 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeError.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeError.kt @@ -2,5 +2,6 @@ package org.signal.donations sealed class StripeError(message: String) : Exception(message) { object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response") + object FailedToCreatePaymentSourceFromCardData : StripeError("Failed to create payment source from card data") class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode") }