mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Add credit card support to badge gifting.
This commit is contained in:
@@ -8,23 +8,17 @@ import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
|
||||
@@ -33,16 +27,8 @@ import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
@@ -57,16 +43,17 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Unified donation fragment which allows users to choose between monthly or one-time donations.
|
||||
*/
|
||||
class DonateToSignalFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.donate_to_signal_fragment
|
||||
) {
|
||||
class DonateToSignalFragment :
|
||||
DSLSettingsFragment(
|
||||
layoutId = R.layout.donate_to_signal_fragment
|
||||
),
|
||||
DonationCheckoutDelegate.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DonateToSignalFragment::class.java)
|
||||
@@ -98,18 +85,10 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
DonateToSignalViewModel.Factory(args.startType)
|
||||
})
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
donationPaymentComponent = requireListener()
|
||||
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind)
|
||||
|
||||
private lateinit var donationPaymentComponent: DonationPaymentComponent
|
||||
private var donationCheckoutDelegate: DonationCheckoutDelegate? = null
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
|
||||
@@ -133,23 +112,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
donationPaymentComponent = requireListener()
|
||||
registerGooglePayCallback()
|
||||
|
||||
setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle ->
|
||||
val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!!
|
||||
handleGatewaySelectionResponse(response)
|
||||
}
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
|
||||
handleDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
|
||||
handleCreditCardResult(result)
|
||||
}
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this)
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
@@ -242,6 +205,11 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
donationCheckoutDelegate = null
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: DonateToSignalState): DSLConfiguration {
|
||||
return configure {
|
||||
space(36.dp)
|
||||
@@ -419,84 +387,6 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
|
||||
when (gatewayResponse.gateway) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
|
||||
GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.")
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
|
||||
Log.d(TAG, "Received credit card information from fragment.")
|
||||
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
|
||||
}
|
||||
|
||||
private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
when (result.status) {
|
||||
DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result)
|
||||
DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
|
||||
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
Log.w(TAG, "Stripe action failed: ${result.action}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
|
||||
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
|
||||
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
|
||||
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
|
||||
label = gatewayResponse.request.label,
|
||||
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
|
||||
if (InAppDonations.isCreditCardAvailable()) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayResponse.request))
|
||||
} else {
|
||||
error("Credit cards are not currently enabled.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerGooglePayCallback() {
|
||||
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
|
||||
onNext = { paymentResult ->
|
||||
viewModel.consumeGatewayRequestForGooglePay()?.let {
|
||||
donationPaymentComponent.stripeRepository.onActivityResult(
|
||||
paymentResult.requestCode,
|
||||
paymentResult.resultCode,
|
||||
paymentResult.data,
|
||||
paymentResult.requestCode,
|
||||
GooglePayRequestCallback(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showErrorDialog(throwable: Throwable) {
|
||||
if (errorDialog != null) {
|
||||
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
|
||||
@@ -543,29 +433,19 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
|
||||
stripePaymentViewModel.providePaymentData(paymentData)
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, request))
|
||||
}
|
||||
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
|
||||
}
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true)
|
||||
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
val error = DonationError.getGooglePayRequestTokenError(
|
||||
source = when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||
},
|
||||
throwable = googlePayException
|
||||
)
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
|
||||
}
|
||||
|
||||
DonationError.routeDonationError(requireContext(), error)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
Log.d(TAG, "Cancelled Google Pay.", true)
|
||||
}
|
||||
override fun onProcessorActionProcessed() {
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,48 +21,56 @@ data class DonateToSignalState(
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val badge: Badge?
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canSetCurrency: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val selectedCurrency: Currency
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val selectableCurrencyCodes: List<String>
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val level: Int
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> 1
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canUpdate: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> false
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
data class OneTimeDonationState(
|
||||
|
||||
@@ -6,5 +6,6 @@ import kotlinx.parcelize.Parcelize
|
||||
@Parcelize
|
||||
enum class DonateToSignalType(val requestCode: Short) : Parcelable {
|
||||
ONE_TIME(16141),
|
||||
MONTHLY(16142);
|
||||
MONTHLY(16142),
|
||||
GIFT(16143)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.manage.Su
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.util.next
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
@@ -57,8 +57,6 @@ class DonateToSignalViewModel(
|
||||
private val _actions = PublishSubject.create<DonateToSignalAction>()
|
||||
private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
|
||||
|
||||
private var gatewayRequest: GatewayRequest? = null
|
||||
|
||||
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
@@ -178,7 +176,8 @@ class DonateToSignalViewModel(
|
||||
label = snapshot.badge!!.description,
|
||||
price = amount.amount,
|
||||
currencyCode = amount.currency.currencyCode,
|
||||
level = snapshot.level.toLong()
|
||||
level = snapshot.level.toLong(),
|
||||
recipientId = Recipient.self().id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,6 +185,7 @@ class DonateToSignalViewModel(
|
||||
return when (snapshot.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState)
|
||||
DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost()
|
||||
DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,18 +348,6 @@ class DonateToSignalViewModel(
|
||||
store.dispose()
|
||||
}
|
||||
|
||||
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
|
||||
Log.d(TAG, "Provided with a gateway request.")
|
||||
Preconditions.checkState(gatewayRequest == null)
|
||||
gatewayRequest = request
|
||||
}
|
||||
|
||||
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
|
||||
val request = gatewayRequest
|
||||
gatewayRequest = null
|
||||
return request
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val startType: DonateToSignalType,
|
||||
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ object DonationPillToggle {
|
||||
DonateToSignalType.MONTHLY -> {
|
||||
presentButtons(model, binding.monthly, binding.oneTime)
|
||||
}
|
||||
DonateToSignalType.GIFT -> {
|
||||
error("Unsupported donation type.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@@ -15,7 +17,10 @@ data class GatewayRequest(
|
||||
val label: String,
|
||||
val price: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val level: Long
|
||||
val level: Long,
|
||||
val recipientId: RecipientId,
|
||||
val additionalMessage: String? = null
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> presentMonthlyText()
|
||||
DonateToSignalType.ONE_TIME -> presentOneTimeText()
|
||||
DonateToSignalType.GIFT -> presentGiftText()
|
||||
}
|
||||
|
||||
space(66.dp)
|
||||
@@ -138,6 +139,25 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentGiftText() {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(resources, args.request.fiat)),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
)
|
||||
space(6.dp)
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.GatewaySelectorBottomSheet__send_a_gift_badge,
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.BodyLargeModifier,
|
||||
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "payment_checkout_mode"
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class StripePaymentInProgressViewModel(
|
||||
@@ -74,6 +72,7 @@ class StripePaymentInProgressViewModel(
|
||||
val errorSource = when (request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource)
|
||||
@@ -81,6 +80,7 @@ class StripePaymentInProgressViewModel(
|
||||
return when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
|
||||
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
|
||||
DonateToSignalType.GIFT -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,17 +164,23 @@ class StripePaymentInProgressViewModel(
|
||||
Log.w(TAG, "Beginning one-time payment pipeline...", true)
|
||||
|
||||
val amount = request.fiat
|
||||
val recipient = Recipient.self().id
|
||||
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
|
||||
|
||||
val continuePayment: Single<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)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
|
||||
.flatMap { nextActionHandler(it) }
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
|
||||
.flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption(amount, paymentIntent.intentId, recipient, null, level) }
|
||||
.flatMapCompletable {
|
||||
oneTimeDonationRepository.waitForOneTimeRedemption(
|
||||
amount,
|
||||
paymentIntent.intentId,
|
||||
request.recipientId,
|
||||
request.additionalMessage,
|
||||
request.level
|
||||
)
|
||||
}
|
||||
}.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
|
||||
Reference in New Issue
Block a user