diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..4f69fdcfb5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/CantProcessSubscriptionPaymentBottomSheetDialogFragment.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.badges.self.expired + +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.settings.models.SplashImage +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.CommunicationActions + +class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() { + override fun bindAdapter(adapter: DSLSettingsAdapter) { + SplashImage.register(adapter) + adapter.submitList(getConfiguration().toMappingModelList()) + } + + private fun getConfiguration(): DSLConfiguration { + return configure { + customPref(SplashImage.Model(R.drawable.ic_card_process)) + + sectionHeaderPref( + title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier) + ) + + textPref( + summary = DSLSettingsText.from( + requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble), + DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) { + CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url)) + }, + DSLSettingsText.CenterModifier + ) + ) + + primaryButton( + text = DSLSettingsText.from(android.R.string.ok) + ) { + dismissAllowingStateLoss() + } + + secondaryButtonNoOutline( + text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again) + ) { + SignalStore.donationsValues().showCantProcessDialog = false + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt index 3b532daf09..5900e16c2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt @@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.BottomSheetUtil @@ -27,9 +28,13 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( } private fun getConfiguration(): DSLConfiguration { - val badge: Badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge + val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()) + val badge: Badge = args.badge + val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason) val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer() + val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE + return configure { customPref(ExpiredBadge.Model(badge)) @@ -50,8 +55,10 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( DSLSettingsText.from( if (badge.isBoost()) { getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired) + } else if (inactive) { + getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_automatically, badge.name) } else { - getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer, badge.name) + getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_canceled) }, DSLSettingsText.CenterModifier ) @@ -109,8 +116,8 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( companion object { @JvmStatic - fun show(badge: Badge, fragmentManager: FragmentManager) { - val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge).build() + fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) { + val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build() val fragment = ExpiredBadgeBottomSheetDialogFragment() fragment.arguments = args.toBundle() diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt index 13f436616a..9fd26fca55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt @@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment( override fun bindAdapter(adapter: DSLSettingsAdapter) { Badge.register(adapter) { badge, _, isFaded -> if (badge.isExpired() || isFaded) { - findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge)) + findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null)) } else { ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt index 6c11dc5bd1..f1bb0b8edc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components.settings import android.content.Context +import android.text.SpannableStringBuilder import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.annotation.StyleRes @@ -81,4 +82,17 @@ sealed class DSLSettingsText { return SpanUtil.bold(charSequence) } } + + class LearnMoreModifier( + @ColorInt private val learnMoreColor: Int, + val onClick: () -> Unit + ) : Modifier { + override fun modify(context: Context, charSequence: CharSequence): CharSequence { + return SpannableStringBuilder(charSequence).append(" ").append( + SpanUtil.learnMore(context, learnMoreColor) { + onClick() + } + ) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt index ebd31236e3..a7042b847f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt @@ -6,10 +6,7 @@ import org.thoughtcrime.securesms.badges.models.Badge * Events that can arise from use of the donations apis. */ sealed class DonationEvent { - class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent() object RequestTokenSuccess : DonationEvent() - class RequestTokenError(val throwable: Throwable) : DonationEvent() - class PaymentConfirmationError(val throwable: Throwable) : DonationEvent() class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent() class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent() object SubscriptionCancelled : DonationEvent() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt deleted file mode 100644 index ed11cb9816..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription - -class DonationExceptions { - class SetupFailed(reason: Throwable) : Exception(reason) - object TimedOutWaitingForTokenRedemption : Exception() - object RedemptionFailed : Exception() -} 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 84b869001f..3fe511ff79 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 @@ -13,6 +13,8 @@ import org.signal.donations.GooglePayApi import org.signal.donations.GooglePayPaymentSource import org.signal.donations.StripeApi import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker @@ -88,13 +90,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable { Log.d(TAG, "Creating payment intent for $price...", true) return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation)) - .onErrorResumeNext { Single.error(DonationExceptions.SetupFailed(it)) } + .onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) } .flatMapCompletable { result -> Log.d(TAG, "Created payment intent for $price.", true) when (result) { - is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too small"))) - is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too large"))) - is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost currency is not supported"))) + is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall()) + is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge()) + is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost()) is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent) } } @@ -141,7 +143,10 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable { Log.d(TAG, "Confirming payment intent...", true) - val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent) + val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext { + Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) + } + val waitOnRedemption = Completable.create { Log.d(TAG, "Confirmed payment intent.", true) @@ -164,20 +169,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } JobTracker.JobState.FAILURE -> { Log.d(TAG, "Boost request response job chain failed permanently.", true) - it.onError(DonationExceptions.RedemptionFailed) + it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)) } else -> { Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST)) } } } else { Log.d(TAG, "Boost redemption timed out waiting for job completion.", true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST)) } } catch (e: InterruptedException) { Log.d(TAG, "Boost redemption job interrupted", e, true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST)) } } @@ -236,20 +241,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } JobTracker.JobState.FAILURE -> { Log.d(TAG, "Subscription request response job chain failed permanently.", true) - it.onError(DonationExceptions.RedemptionFailed) + it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)) } else -> { Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION)) } } } else { Log.d(TAG, "Subscription request response job timed out.", true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION)) } } catch (e: InterruptedException) { Log.w(TAG, "Subscription request response interrupted.", e, true) - it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption) + it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION)) } } }.doOnError { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt index cab4dad3f1..69c89c1ec8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost +import android.content.DialogInterface import android.text.SpannableStringBuilder import android.view.View import androidx.appcompat.app.AlertDialog @@ -10,6 +11,7 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.LottieAnimationView import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.signal.core.util.DimensionUnit import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R @@ -22,8 +24,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +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 import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure @@ -63,6 +67,8 @@ class BoostFragment : DSLSettingsBottomSheetFragment( private lateinit var processingDonationPaymentDialog: AlertDialog private lateinit var donationPaymentComponent: DonationPaymentComponent + private var errorDialog: DialogInterface? = null + private val sayThanks: CharSequence by lazy { SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30)) .append(" ") @@ -118,10 +124,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment( lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent -> when (event) { - is DonationEvent.GooglePayUnavailableError -> Unit - is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable) is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge) - is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(event.throwable)) DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay") DonationEvent.SubscriptionCancelled -> Unit is DonationEvent.SubscriptionCancellationFailed -> Unit @@ -130,6 +133,13 @@ class BoostFragment : DSLSettingsBottomSheetFragment( lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe { viewModel.onActivityResult(it.requestCode, it.resultCode, it.data) } + + lifecycleDisposable += DonationError + .getErrorsForSource(DonationErrorSource.BOOST) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { donationError -> + onPaymentError(donationError) + } } override fun onDestroyView() { @@ -240,37 +250,21 @@ class BoostFragment : DSLSettingsBottomSheetFragment( } private fun onPaymentError(throwable: Throwable?) { - if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) { - Log.w(TAG, "Timed out while redeeming token", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__still_processing) - .setMessage(R.string.DonationsErrors__your_payment_is_still) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - findNavController().popBackStack() - } - .show() - } else if (throwable is DonationExceptions.SetupFailed) { - Log.w(TAG, "Error occurred while processing payment", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__error_processing_payment) - .setMessage(R.string.DonationsErrors__your_payment) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - findNavController().popBackStack() - } - .show() - } else { - Log.w(TAG, "Error occurred while trying to redeem token", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__couldnt_add_badge) - .setMessage(R.string.DonationsErrors__your_badge_could_not) - .setPositiveButton(R.string.Subscription__contact_support) { dialog, _ -> - dialog.dismiss() - findNavController().popBackStack() - } - .show() + Log.w(TAG, "onPaymentError", throwable, true) + + if (errorDialog != null) { + Log.i(TAG, "Already displaying an error dialog. Skipping.") + return } + + errorDialog = DonationErrorDialogs.show( + requireContext(), throwable, + object : DonationErrorDialogs.DialogCallback() { + override fun onDialogDismissed() { + findNavController().popBackStack() + } + } + ) } private fun startAnimationAboveSelectedBoost(view: View) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index d8319c4b25..7a55912ca8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -18,12 +18,14 @@ import org.signal.donations.GooglePayApi import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.livedata.Store -import java.lang.NumberFormatException import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -110,7 +112,12 @@ class BoostViewModel( disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy( onComplete = { store.update { it.copy(isGooglePayAvailable = true) } }, - onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) } + onError = { + DonationError.routeDonationError( + ApplicationDependencies.getApplication(), + DonationError.getGooglePayNotAvailableError(DonationErrorSource.BOOST, it) + ) + } ) disposables += currencyObservable.subscribeBy { currency -> @@ -146,7 +153,13 @@ class BoostViewModel( donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy( onError = { throwable -> store.update { it.copy(stage = BoostState.Stage.READY) } - eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable)) + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + Log.w(TAG, "Failed to complete payment or redemption", throwable, true) + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) }, onComplete = { store.update { it.copy(stage = BoostState.Stage.READY) } @@ -160,7 +173,7 @@ class BoostViewModel( override fun onError(googlePayException: GooglePayApi.GooglePayException) { store.update { it.copy(stage = BoostState.Stage.READY) } - eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException)) + DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.BOOST, googlePayException)) } override fun onCancelled() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt new file mode 100644 index 0000000000..8d6b31a568 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -0,0 +1,159 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +import android.content.Context +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.PublishSubject +import io.reactivex.rxjava3.subjects.Subject +import org.signal.core.util.logging.Log +import org.signal.donations.StripeDeclineCode +import org.signal.donations.StripeError + +sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) { + + /** + * Google Pay errors, which happen well before a user would ever be charged. + */ + sealed class GooglePayError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) { + class NotAvailableError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause) + class RequestTokenError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause) + } + + /** + * Boost validation errors, which occur before the user could be charged. + */ + sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) { + object AmountTooSmallError : BoostError("Amount is too small") + object AmountTooLargeError : BoostError("Amount is too large") + object InvalidCurrencyError : BoostError("Currency is not supported") + } + + /** + * Stripe setup errors, which occur before the user could be charged. These are either + * payment processing handed to Stripe from the CC company (in the case of a Boost payment + * intent confirmation error) or other generic error from Stripe. + */ + sealed class PaymentSetupError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) { + /** + * Payment setup failed in some generic fashion. + */ + class GenericError(source: DonationErrorSource, cause: Throwable) : PaymentSetupError(source, cause) + + /** + * Payment setup failed in some way, which we are told about by Stripe. + */ + class CodedError(source: DonationErrorSource, cause: Throwable, val errorCode: String) : PaymentSetupError(source, cause) + + /** + * Payment failed by the credit card processor, with a specific reason told to us by Stripe. + */ + class DeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode) : PaymentSetupError(source, cause) + } + + /** + * Errors that can be thrown after we submit a payment to Stripe. It is + * assumed that at this point, anything we submit *could* happen, so we can no + * longer safely assume a user has not been charged. Payment errors explicitly + * originate from Signal service. + */ + sealed class PaymentProcessingError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) { + class GenericError(source: DonationErrorSource) : DonationError(source, Exception("Generic Payment Error")) + } + + /** + * Errors that can occur during the badge redemption process. + */ + sealed class BadgeRedemptionError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) { + /** + * Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that + * redemption failed, just that it is taking longer than we can reasonably show a spinner. + */ + class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete.")) + + /** + * Some generic error not otherwise accounted for occurred during the redemption process. + */ + class GenericError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to add badge to account.")) + } + + companion object { + + private val TAG = Log.tag(DonationError::class.java) + + private val donationErrorSubjectSourceMap: Map> = DonationErrorSource.values().associate { source -> + source to PublishSubject.create() + } + + @JvmStatic + fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable { + return donationErrorSubjectSourceMap[donationErrorSource]!! + } + + /** + * Route a given donation error, which will either pipe it out to an appropriate subject + * or, if the subject has no observers, post it as a notification. + */ + @JvmStatic + fun routeDonationError(context: Context, error: DonationError) { + val subject: Subject = donationErrorSubjectSourceMap[error.source]!! + when { + subject.hasObservers() -> { + Log.i(TAG, "Routing donation error to subject ${error.source} dialog", error) + subject.onNext(error) + } + else -> { + Log.i(TAG, "Routing donation error to subject ${error.source} notification", error) + DonationErrorNotifications.displayErrorNotification(context, error) + } + } + } + + @JvmStatic + fun getGooglePayNotAvailableError(source: DonationErrorSource, throwable: Throwable): DonationError { + return GooglePayError.NotAvailableError(source, throwable) + } + + @JvmStatic + fun getGooglePayRequestTokenError(source: DonationErrorSource, throwable: Throwable): DonationError { + return GooglePayError.RequestTokenError(source, throwable) + } + + /** + * Converts a throwable into a payment setup error. This should only be used when + * handling errors handed back via the Stripe API, when we know for sure that no + * charge has occurred. + */ + @JvmStatic + fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable): DonationError { + return if (throwable is StripeError.PostError) { + val declineCode: StripeDeclineCode? = throwable.declineCode + val errorCode: String? = throwable.errorCode + + when { + declineCode != null -> PaymentSetupError.DeclinedError(source, throwable, declineCode) + errorCode != null -> PaymentSetupError.CodedError(source, throwable, errorCode) + else -> PaymentSetupError.GenericError(source, throwable) + } + } else { + PaymentSetupError.GenericError(source, throwable) + } + } + + @JvmStatic + fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError + + @JvmStatic + fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError + + @JvmStatic + fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError + + @JvmStatic + fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source) + + @JvmStatic + fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source) + + @JvmStatic + fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt new file mode 100644 index 0000000000..bb93287598 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +import android.content.Context +import android.content.DialogInterface +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.help.HelpFragment +import org.thoughtcrime.securesms.util.CommunicationActions + +/** + * Donation Error Dialogs. + */ +object DonationErrorDialogs { + /** + * Displays a dialog, and returns a handle to it for dismissal. + */ + fun show(context: Context, throwable: Throwable?, callback: DialogCallback): DialogInterface { + val builder = MaterialAlertDialogBuilder(context) + + builder.setOnDismissListener { callback.onDialogDismissed() } + + val params = DonationErrorParams.create(context, throwable, callback) + + if (params.title != null) { + builder.setTitle(params.title) + } + + if (params.message != null) { + builder.setMessage(params.message) + } + + if (params.positiveAction != null) { + builder.setPositiveButton(params.positiveAction.label) { _, _ -> params.positiveAction.action() } + } + + if (params.negativeAction != null) { + builder.setNegativeButton(params.negativeAction.label) { _, _ -> params.negativeAction.action() } + } + + return builder.show() + } + + open class DialogCallback : DonationErrorParams.Callback { + + override fun onCancel(context: Context): DonationErrorParams.ErrorAction? { + return DonationErrorParams.ErrorAction( + label = android.R.string.cancel, + action = {} + ) + } + + override fun onOk(context: Context): DonationErrorParams.ErrorAction? { + return DonationErrorParams.ErrorAction( + label = android.R.string.ok, + action = {} + ) + } + + override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction? { + return DonationErrorParams.ErrorAction( + label = R.string.DeclineCode__go_to_google_pay, + action = { + CommunicationActions.openBrowserLink(context, context.getString(R.string.google_pay_url)) + } + ) + } + + override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction? { + return DonationErrorParams.ErrorAction( + label = R.string.DeclineCode__learn_more, + action = { + CommunicationActions.openBrowserLink(context, context.getString(R.string.donation_decline_code_error_url)) + } + ) + } + + override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction { + return DonationErrorParams.ErrorAction( + label = R.string.Subscription__contact_support, + action = { + context.startActivity(AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX)) + } + ) + } + + open fun onDialogDismissed() = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorNotifications.kt new file mode 100644 index 0000000000..5699842ff9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorNotifications.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.help.HelpFragment +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.NotificationIds + +/** + * Donation-related push notifications. + */ +object DonationErrorNotifications { + fun displayErrorNotification(context: Context, donationError: DonationError) { + val parameters = DonationErrorParams.create(context, donationError, NotificationCallback) + val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(parameters.title)) + .setContentText(context.getString(parameters.message)).apply { + if (parameters.positiveAction != null) { + addAction(context, parameters.positiveAction) + } + + if (parameters.negativeAction != null) { + addAction(context, parameters.negativeAction) + } + } + .build() + + NotificationManagerCompat + .from(context) + .notify(NotificationIds.DONOR_BADGE_FAILURE, notification) + } + + private fun NotificationCompat.Builder.addAction(context: Context, errorAction: DonationErrorParams.ErrorAction) { + addAction( + NotificationCompat.Action.Builder( + null, + context.getString(errorAction.label), + errorAction.action.invoke() + ).build() + ) + } + + private object NotificationCallback : DonationErrorParams.Callback { + + override fun onCancel(context: Context): DonationErrorParams.ErrorAction? = null + + override fun onOk(context: Context): DonationErrorParams.ErrorAction? = null + + override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction { + return createAction( + context = context, + label = R.string.DeclineCode__learn_more, + actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.donation_decline_code_error_url))) + ) + } + + override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction { + return createAction( + context = context, + label = R.string.DeclineCode__go_to_google_pay, + actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.google_pay_url))) + ) + } + + override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction { + return createAction( + context = context, + label = R.string.Subscription__contact_support, + actionIntent = AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX) + ) + } + + private fun createAction( + context: Context, + label: Int, + actionIntent: Intent + ): DonationErrorParams.ErrorAction { + return DonationErrorParams.ErrorAction( + label = label, + action = { + PendingIntent.getActivity( + context, + 0, + actionIntent, + if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0 + ) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt new file mode 100644 index 0000000000..987403dc79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +import android.content.Context +import androidx.annotation.StringRes +import org.signal.donations.StripeDeclineCode +import org.thoughtcrime.securesms.R + +class DonationErrorParams private constructor( + @StringRes val title: Int, + @StringRes val message: Int, + val positiveAction: ErrorAction?, + val negativeAction: ErrorAction? +) { + class ErrorAction( + @StringRes val label: Int, + val action: () -> V + ) + + companion object { + fun create( + context: Context, + throwable: Throwable?, + callback: Callback + ): DonationErrorParams { + return when (throwable) { + is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback) + is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams( + title = R.string.DonationsErrors__still_processing, + message = R.string.DonationsErrors__your_payment_is_still, + positiveAction = callback.onOk(context), + negativeAction = null + ) + else -> DonationErrorParams( + title = R.string.DonationsErrors__couldnt_add_badge, + message = R.string.DonationsErrors__your_badge_could_not, + positiveAction = callback.onContactSupport(context), + negativeAction = null + ) + } + } + + private fun getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback): DonationErrorParams { + return when (declinedError.declineCode) { + is StripeDeclineCode.Known -> when (declinedError.declineCode.code) { + StripeDeclineCode.Code.APPROVE_WITH_ID -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again) + StripeDeclineCode.Code.CALL_ISSUER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem) + StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase) + StripeDeclineCode.Code.EXPIRED_CARD -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_has_expired) + StripeDeclineCode.Code.INCORRECT_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect) + StripeDeclineCode.Code.INCORRECT_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect) + StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds) + StripeDeclineCode.Code.INVALID_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect) + StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_month) + StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_year) + StripeDeclineCode.Code.INVALID_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect) + StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again) + StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again) + StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again) + else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank) + } + else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank) + } + } + + private fun getLearnMoreParams(context: Context, callback: Callback, message: Int): DonationErrorParams { + return DonationErrorParams( + title = R.string.DonationsErrors__error_processing_payment, + message = message, + positiveAction = callback.onOk(context), + negativeAction = callback.onLearnMore(context) + ) + } + + private fun getGoToGooglePayParams(context: Context, callback: Callback, message: Int): DonationErrorParams { + return DonationErrorParams( + title = R.string.DonationsErrors__error_processing_payment, + message = message, + positiveAction = callback.onGoToGooglePay(context), + negativeAction = callback.onCancel(context) + ) + } + } + + interface Callback { + fun onOk(context: Context): ErrorAction? + fun onCancel(context: Context): ErrorAction? + fun onLearnMore(context: Context): ErrorAction? + fun onContactSupport(context: Context): ErrorAction? + fun onGoToGooglePay(context: Context): ErrorAction? + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt new file mode 100644 index 0000000000..52a522914d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +enum class DonationErrorSource(private val code: String) { + BOOST("boost"), + SUBSCRIPTION("subscription"), + KEEP_ALIVE("keep-alive"), + UNKNOWN("unknown"); + + fun serialize(): String = code + + companion object { + @JvmStatic + fun deserialize(code: String): DonationErrorSource { + return values().firstOrNull { it.code == code } ?: UNKNOWN + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt new file mode 100644 index 0000000000..46954154db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.errors + +/** + * Error states that can occur if we detect that a user's subscription has been cancelled and the manual + * cancellation flag is not set. + */ +enum class UnexpectedSubscriptionCancellation(val status: String) { + PAST_DUE("past_due"), + CANCELED("canceled"), + UNPAID("unpaid"), + INACTIVE("user-was-inactive"); + + companion object { + @JvmStatic + fun fromStatus(status: String?): UnexpectedSubscriptionCancellation? { + return values().firstOrNull { it.status == status } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt index c0ad587e13..4e69ea892c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe +import android.content.DialogInterface import android.graphics.Color import android.text.SpannableStringBuilder import androidx.appcompat.app.AlertDialog @@ -8,6 +9,7 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.signal.core.util.DimensionUnit import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney @@ -20,18 +22,18 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository +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 import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.Progress import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.help.HelpFragment import org.thoughtcrime.securesms.keyboard.findListener -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.subscription.Subscription import org.thoughtcrime.securesms.util.LifecycleDisposable @@ -63,6 +65,8 @@ class SubscribeFragment : DSLSettingsFragment( private lateinit var processingDonationPaymentDialog: AlertDialog private lateinit var donationPaymentComponent: DonationPaymentComponent + private var errorDialog: DialogInterface? = null + private val viewModel: SubscribeViewModel by viewModels( factoryProducer = { SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationPaymentComponent.donationPaymentRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE) @@ -97,10 +101,7 @@ class SubscribeFragment : DSLSettingsFragment( lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) lifecycleDisposable += viewModel.events.subscribe { when (it) { - is DonationEvent.GooglePayUnavailableError -> Unit - is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable) is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge) - is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(it.throwable)) DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay") DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled() is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable) @@ -109,6 +110,13 @@ class SubscribeFragment : DSLSettingsFragment( lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe { viewModel.onActivityResult(it.requestCode, it.resultCode, it.data) } + + lifecycleDisposable += DonationError + .getErrorsForSource(DonationErrorSource.SUBSCRIPTION) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { donationError -> + onPaymentError(donationError) + } } override fun onDestroyView() { @@ -277,49 +285,21 @@ class SubscribeFragment : DSLSettingsFragment( } private fun onPaymentError(throwable: Throwable?) { - if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) { - Log.w(TAG, "Timeout occurred while redeeming token", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__still_processing) - .setMessage(R.string.DonationsErrors__your_payment_is_still) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - requireActivity().finish() - requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext())) - } - .show() - } else if (throwable is DonationExceptions.SetupFailed) { - Log.w(TAG, "Error occurred while processing payment", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__error_processing_payment) - .setMessage(R.string.DonationsErrors__your_payment) - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - } - .show() - } else if (SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) { - Log.w(TAG, "Stripe failed to process payment", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__error_processing_payment) - .setMessage(R.string.DonationsErrors__your_badge_could_not_be_added) - .setPositiveButton(R.string.Subscription__contact_support) { dialog, _ -> - dialog.dismiss() - requireActivity().finish() - requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX)) - } - .show() - } else { - Log.w(TAG, "Error occurred while trying to redeem token", throwable, true) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.DonationsErrors__couldnt_add_badge) - .setMessage(R.string.DonationsErrors__your_badge_could_not) - .setPositiveButton(R.string.Subscription__contact_support) { dialog, _ -> - dialog.dismiss() - requireActivity().finish() - requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX)) - } - .show() + Log.w(TAG, "onPaymentError", throwable, true) + + if (errorDialog != null) { + Log.i(TAG, "Already displaying an error dialog. Skipping.") + return } + + errorDialog = DonationErrorDialogs.show( + requireContext(), throwable, + object : DonationErrorDialogs.DialogCallback() { + override fun onDialogDismissed() { + findNavController().popBackStack() + } + } + ) } private fun onSubscriptionCancelled() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt index 57dca4e999..fec7c3391c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt @@ -18,9 +18,11 @@ import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.GooglePayApi import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.subscription.LevelUpdate @@ -131,7 +133,12 @@ class SubscribeViewModel( disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy( onComplete = { store.update { it.copy(isGooglePayAvailable = true) } }, - onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) } + onError = { + DonationError.routeDonationError( + ApplicationDependencies.getApplication(), + DonationError.getGooglePayNotAvailableError(DonationErrorSource.BOOST, it) + ) + } ) disposables += currency.subscribe { selection -> @@ -170,6 +177,7 @@ class SubscribeViewModel( SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().clearLevelOperations() SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false + SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null MultiDeviceSubscriptionSyncRequestJob.enqueue() } } else { @@ -186,6 +194,7 @@ class SubscribeViewModel( SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().clearLevelOperations() SignalStore.donationsValues().markUserManuallyCancelled() + SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null refreshActiveSubscription() MultiDeviceSubscriptionSyncRequestJob.enqueue() donationPaymentRepository.scheduleSyncForAccountRecordChange() @@ -222,13 +231,20 @@ class SubscribeViewModel( val setup = ensureSubscriberId .andThen(cancelActiveSubscriptionIfNecessary()) .andThen(continueSetup) - .onErrorResumeNext { Completable.error(DonationExceptions.SetupFailed(it)) } + .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } setup.andThen(setLevel).subscribeBy( onError = { throwable -> refreshActiveSubscription() store.update { it.copy(stage = SubscribeState.Stage.READY) } - eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable)) + + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + Log.w(TAG, "Failed to complete payment or redemption", throwable, true) + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) }, onComplete = { store.update { it.copy(stage = SubscribeState.Stage.READY) } @@ -242,7 +258,7 @@ class SubscribeViewModel( override fun onError(googlePayException: GooglePayApi.GooglePayException) { store.update { it.copy(stage = SubscribeState.Stage.READY) } - eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException)) + DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.SUBSCRIPTION, googlePayException)) } override fun onCancelled() { @@ -262,7 +278,13 @@ class SubscribeViewModel( }, onError = { throwable -> store.update { it.copy(stage = SubscribeState.Stage.READY) } - eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable)) + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + Log.w(TAG, "Failed to complete payment or redemption", throwable, true) + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt new file mode 100644 index 0000000000..c3321f8429 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.components.settings.models + +import android.view.View +import android.widget.ImageView +import androidx.annotation.DrawableRes +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder + +/** + * Renders a single image, horizontally centered. + */ +object SplashImage { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.splash_image)) + } + + class Model(@DrawableRes val splashImageResId: Int) : PreferenceModel() { + override fun areItemsTheSame(newItem: Model): Boolean { + return newItem.splashImageResId == splashImageResId + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val splashImageView: ImageView = itemView as ImageView + + override fun bind(model: Model) { + splashImageView.setImageResource(model.splashImageResId) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 58609c700d..9c1002a6c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -86,6 +86,7 @@ import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.badges.BadgeImageView; import org.thoughtcrime.securesms.badges.models.Badge; +import org.thoughtcrime.securesms.badges.self.expired.CantProcessSubscriptionPaymentBottomSheetDialogFragment; import org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment; import org.thoughtcrime.securesms.components.RatingManager; import org.thoughtcrime.securesms.components.SearchToolbar; @@ -105,6 +106,7 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder; import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; import org.thoughtcrime.securesms.conversation.ConversationFragment; @@ -359,13 +361,20 @@ public class ConversationListFragment extends MainFragment implements ActionMode RecaptchaProofBottomSheetFragment.show(getChildFragmentManager()); } - Badge expiredBadge = SignalStore.donationsValues().getExpiredBadge(); + Badge expiredBadge = SignalStore.donationsValues().getExpiredBadge(); + String subscriptionCancellationReason = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationReason(); + UnexpectedSubscriptionCancellation unexpectedSubscriptionCancellation = UnexpectedSubscriptionCancellation.fromStatus(subscriptionCancellationReason); + if (expiredBadge != null) { SignalStore.donationsValues().setExpiredBadge(null); if (expiredBadge.isBoost() || !SignalStore.donationsValues().isUserManuallyCancelled()) { - ExpiredBadgeBottomSheetDialogFragment.show(expiredBadge, getParentFragmentManager()); + Log.w(TAG, "Displaying bottom sheet for an expired badge", true); + ExpiredBadgeBottomSheetDialogFragment.show(expiredBadge, unexpectedSubscriptionCancellation, getParentFragmentManager()); } + } else if (unexpectedSubscriptionCancellation != null && !SignalStore.donationsValues().isUserManuallyCancelled() && SignalStore.donationsValues().getShowCantProcessDialog()) { + Log.w(TAG, "Displaying bottom sheet for unexpected cancellation: " + unexpectedSubscriptionCancellation, true); + new CantProcessSubscriptionPaymentBottomSheetDialogFragment().show(getChildFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index 170bfbea33..88ddc9f758 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.jobs; +import android.content.Context; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -14,12 +16,13 @@ import org.signal.zkgroup.receipts.ReceiptCredentialPresentation; import org.signal.zkgroup.receipts.ReceiptCredentialRequestContext; import org.signal.zkgroup.receipts.ReceiptCredentialResponse; import org.signal.zkgroup.receipts.ReceiptSerial; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.subscription.DonorBadgeNotifications; import org.whispersystems.signalservice.internal.ServiceResponse; import java.io.IOException; @@ -95,7 +98,6 @@ public class BoostReceiptRequestResponseJob extends BaseJob { @Override public void onFailure() { - DonorBadgeNotifications.RedemptionFailed.INSTANCE.show(context); } @Override @@ -122,12 +124,12 @@ public class BoostReceiptRequestResponseJob extends BaseJob { .blockingGet(); if (response.getApplicationError().isPresent()) { - handleApplicationError(response); - setOutputData(new Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_PAYMENT_FAILURE, true).build()); + handleApplicationError(context, response); } else if (response.getResult().isPresent()) { ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get()); if (!isCredentialValid(receiptCredential)) { + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)); throw new IOException("Could not validate receipt credential"); } @@ -142,7 +144,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob { } } - private static void handleApplicationError(ServiceResponse response) throws Exception { + private static void handleApplicationError(Context context, ServiceResponse response) throws Exception { Throwable applicationException = response.getApplicationError().get(); switch (response.getStatus()) { case 204: @@ -150,12 +152,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob { throw new RetryableException(); case 400: Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)); throw new Exception(applicationException); case 402: Log.w(TAG, "User payment failed.", applicationException, true); - break; + DonationError.routeDonationError(context, DonationError.genericPaymentFailure(DonationErrorSource.BOOST)); + throw new Exception(applicationException); case 409: Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)); throw new Exception(applicationException); default: Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index 9f286f027b..df1f46fefe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -4,12 +4,13 @@ import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; import org.signal.zkgroup.receipts.ReceiptCredentialPresentation; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.subscription.DonorBadgeNotifications; import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.ServiceResponse; @@ -27,11 +28,13 @@ public class DonationReceiptRedemptionJob extends BaseJob { public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption"; public static final String KEY = "DonationReceiptRedemptionJob"; public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation"; - public static final String INPUT_PAYMENT_FAILURE = "data.payment.failure"; - public static final String INPUT_KEEP_ALIVE_409 = "data.keep.alive.409"; + public static final String DATA_ERROR_SOURCE = "data.error.source"; - public static DonationReceiptRedemptionJob createJobForSubscription() { + private final DonationErrorSource errorSource; + + public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) { return new DonationReceiptRedemptionJob( + errorSource, new Job.Parameters .Builder() .addConstraint(NetworkConstraint.KEY) @@ -44,6 +47,7 @@ public class DonationReceiptRedemptionJob extends BaseJob { public static DonationReceiptRedemptionJob createJobForBoost() { return new DonationReceiptRedemptionJob( + DonationErrorSource.BOOST, new Job.Parameters .Builder() .addConstraint(NetworkConstraint.KEY) @@ -53,13 +57,14 @@ public class DonationReceiptRedemptionJob extends BaseJob { .build()); } - private DonationReceiptRedemptionJob(@NonNull Job.Parameters parameters) { + private DonationReceiptRedemptionJob(@NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) { super(parameters); + this.errorSource = errorSource; } @Override public @NonNull Data serialize() { - return Data.EMPTY; + return new Data.Builder().putString(DATA_ERROR_SOURCE, errorSource.serialize()).build(); } @Override @@ -69,22 +74,6 @@ public class DonationReceiptRedemptionJob extends BaseJob { @Override public void onFailure() { - Data inputData = getInputData(); - - if (inputData != null && inputData.getBooleanOrDefault(INPUT_PAYMENT_FAILURE, false)) { - DonorBadgeNotifications.PaymentFailed.INSTANCE.show(context); - } else if (inputData != null && inputData.getBooleanOrDefault(INPUT_KEEP_ALIVE_409, false)) { - Log.i(TAG, "Skipping redemption due to 409 error during keep-alive."); - return; - } else { - DonorBadgeNotifications.RedemptionFailed.INSTANCE.show(context); - } - - if (isForSubscription()) { - Log.d(TAG, "Marking subscription failure", true); - SignalStore.donationsValues().markSubscriptionRedemptionFailed(); - MultiDeviceSubscriptionSyncRequestJob.enqueue(); - } } @Override @@ -96,10 +85,6 @@ public class DonationReceiptRedemptionJob extends BaseJob { return; } - if (inputData.getBooleanOrDefault(INPUT_PAYMENT_FAILURE, false)) { - throw new Exception("Payment failed."); - } - byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION); if (presentationBytes == null) { Log.d(TAG, "No response data. Exiting.", null, true); @@ -121,6 +106,7 @@ public class DonationReceiptRedemptionJob extends BaseJob { throw new RetryableException(); } else { Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(errorSource)); throw new IOException(response.getApplicationError().get()); } } else if (response.getExecutionError().isPresent()) { @@ -151,7 +137,10 @@ public class DonationReceiptRedemptionJob extends BaseJob { public static class Factory implements Job.Factory { @Override public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) { - return new DonationReceiptRedemptionJob(parameters); + String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize()); + DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource); + + return new DonationReceiptRedemptionJob(errorSource, parameters); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index df467997de..f26de97fd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -11,6 +11,7 @@ import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.badges.BadgeRepository; import org.thoughtcrime.securesms.badges.Badges; import org.thoughtcrime.securesms.badges.models.Badge; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.subscription.Subscriber; import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -28,6 +30,8 @@ import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; +import org.whispersystems.signalservice.internal.ServiceResponse; import java.io.IOException; import java.util.Comparator; @@ -218,6 +222,29 @@ public class RefreshOwnProfileJob extends BaseJob { if (!SignalStore.donationsValues().isUserManuallyCancelled()) { Log.d(TAG, "Detected an unexpected subscription expiry.", true); + Subscriber subscriber = SignalStore.donationsValues().getSubscriber(); + + boolean isDueToPaymentFailure = false; + if (subscriber != null) { + ServiceResponse response = ApplicationDependencies.getDonationsService() + .getSubscription(subscriber.getSubscriberId()) + .blockingGet(); + + if (response.getResult().isPresent()) { + ActiveSubscription activeSubscription = response.getResult().get(); + if (activeSubscription.isFailedPayment()) { + Log.d(TAG, "Unexpected expiry due to payment failure.", true); + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(activeSubscription.getActiveSubscription().getStatus()); + isDueToPaymentFailure = true; + } + } + } + + if (!isDueToPaymentFailure) { + Log.d(TAG, "Unexpected expiry due to inactivity.", true); + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(UnexpectedSubscriptionCancellation.INACTIVE.getStatus()); + } + MultiDeviceSubscriptionSyncRequestJob.enqueue(); SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 6d21436950..9f57b08d85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -13,6 +13,8 @@ import org.signal.zkgroup.receipts.ReceiptCredentialPresentation; import org.signal.zkgroup.receipts.ReceiptCredentialRequestContext; import org.signal.zkgroup.receipts.ReceiptCredentialResponse; import org.signal.zkgroup.receipts.ReceiptSerial; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError; +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; @@ -88,7 +90,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive) { Subscriber subscriber = SignalStore.donationsValues().requireSubscriber(); SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive); - DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(); + DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource()); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription(); return ApplicationDependencies.getJobManager() @@ -139,9 +141,9 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { Log.w(TAG, "Subscription is null.", true); throw new RetryableException(); } else if (subscription.isFailedPayment()) { - Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + "). Passing through to redemption job.", true); - onPaymentFailure(); - return; + Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true); + onPaymentFailure(subscription.getStatus()); + throw new Exception("Subscription has a payment failure: " + subscription.getStatus()); } else if (!subscription.isActive()) { Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true); throw new RetryableException(); @@ -162,6 +164,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get()); if (!isCredentialValid(subscription, receiptCredential)) { + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); throw new IOException("Could not validate receipt credential"); } @@ -185,6 +188,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { return activeSubscription.getResult().get().getActiveSubscription(); } else if (activeSubscription.getApplicationError().isPresent()) { Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); throw new IOException(activeSubscription.getApplicationError().get()); } else { throw new RetryableException(); @@ -221,42 +225,53 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { throw new RetryableException(); case 400: Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); throw new Exception(response.getApplicationError().get()); case 402: - Log.w(TAG, "Subscription payment failure in credential response. Passing through to redemption job.", true); - onPaymentFailure(); - break; + Log.w(TAG, "Subscription payment failure in credential response.", response.getApplicationError().get(), true); + onPaymentFailure(null); + throw new Exception(response.getApplicationError().get()); case 403: Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); throw new Exception(response.getApplicationError().get()); case 404: Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); throw new Exception(response.getApplicationError().get()); case 409: onAlreadyRedeemed(response); - break; + throw new Exception(response.getApplicationError().get()); default: Log.w(TAG, "Encountered a server failure response: " + response.getStatus(), response.getApplicationError().get(), true); throw new RetryableException(); } } - private void onPaymentFailure() { + private void onPaymentFailure(@Nullable String status) { SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); - setOutputData(new Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_PAYMENT_FAILURE, true).build()); - MultiDeviceSubscriptionSyncRequestJob.enqueue(); + if (status == null) { + DonationError.routeDonationError(context, DonationError.genericPaymentFailure(getErrorSource())); + } else { + SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status); + MultiDeviceSubscriptionSyncRequestJob.enqueue(); + } } - private void onAlreadyRedeemed(ServiceResponse response) throws Exception { + private void onAlreadyRedeemed(ServiceResponse response) { if (isForKeepAlive) { Log.i(TAG, "KeepAlive: Latest paid receipt on subscription already redeemed with a different request credential, ignoring.", response.getApplicationError().get(), true); - setOutputData(new Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).build()); } else { Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true); - throw new Exception(response.getApplicationError().get()); + DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource())); } } + private DonationErrorSource getErrorSource() { + return isForKeepAlive ? DonationErrorSource.KEEP_ALIVE : DonationErrorSource.SUBSCRIPTION; + } + /** * Checks that the generated Receipt Credential has the following characteristics * - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index d54258a4c5..2cf3c18ff3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -34,6 +34,8 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign private const val DISPLAY_BADGES_ON_PROFILE = "donation.display.badges.on.profile" private const val SUBSCRIPTION_REDEMPTION_FAILED = "donation.subscription.redemption.failed" private const val SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT = "donation.should.cancel.subscription.before.next.subscribe.attempt" + private const val SUBSCRIPTION_CANCELATION_REASON = "donation.subscription.cancelation.reason" + private const val SHOW_CANT_PROCESS_DIALOG = "show.cant.process.dialog" } override fun onFirstEverAppLaunch() = Unit @@ -42,7 +44,9 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign KEY_CURRENCY_CODE_BOOST, KEY_LAST_KEEP_ALIVE_LAUNCH, KEY_LAST_END_OF_PERIOD_SECONDS, - SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT + SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, + SUBSCRIPTION_CANCELATION_REASON, + SHOW_CANT_PROCESS_DIALOG ) private val subscriptionCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) } @@ -224,6 +228,10 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false) } + var unexpectedSubscriptionCancelationReason: String? by stringValue(SUBSCRIPTION_CANCELATION_REASON, null) + + var showCantProcessDialog: Boolean by booleanValue(SHOW_CANT_PROCESS_DIALOG, true) + /** * Denotes that the previous attempt to subscribe failed in some way. Either an * automatic renewal failed resulting in an unexpected expiration, or payment failed diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index 5c2c7af29a..ebec892283 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -13,8 +13,7 @@ public final class NotificationIds { public static final int LEGACY_SQLCIPHER_MIGRATION = 494949; public static final int USER_NOTIFICATION_MIGRATION = 525600; public static final int DEVICE_TRANSFER = 625420; - public static final int SUBSCRIPTION_VERIFY_FAILED = 630001; - public static final int BOOST_PAYMENT_FAILED = 630002; + public static final int DONOR_BADGE_FAILURE = 630001; private NotificationIds() { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java index 2cf8e308eb..fa74457b7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -161,6 +161,7 @@ public final class StorageSyncHelper { if (update.getNew().isSubscriptionManuallyCancelled()) { SignalStore.donationsValues().markUserManuallyCancelled(); + SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(null); } else { SignalStore.donationsValues().clearUserManuallyCancelled(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/DonorBadgeNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/DonorBadgeNotifications.kt deleted file mode 100644 index 0871959ed3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/DonorBadgeNotifications.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.thoughtcrime.securesms.subscription - -import android.app.PendingIntent -import android.content.Context -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity -import org.thoughtcrime.securesms.help.HelpFragment -import org.thoughtcrime.securesms.notifications.NotificationChannels -import org.thoughtcrime.securesms.notifications.NotificationIds - -sealed class DonorBadgeNotifications { - object RedemptionFailed : DonorBadgeNotifications() { - override fun show(context: Context) { - val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(context.getString(R.string.DonationsErrors__couldnt_add_badge)) - .setContentText(context.getString(R.string.Subscription__please_contact_support_for_more_information)) - .addAction( - NotificationCompat.Action.Builder( - null, - context.getString(R.string.Subscription__contact_support), - PendingIntent.getActivity( - context, - 0, - AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX), - if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0 - ) - ).build() - ) - .build() - - NotificationManagerCompat - .from(context) - .notify(NotificationIds.SUBSCRIPTION_VERIFY_FAILED, notification) - } - } - - object PaymentFailed : DonorBadgeNotifications() { - override fun show(context: Context) { - val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(context.getString(R.string.DonationsErrors__error_processing_payment)) - .setContentText(context.getString(R.string.DonationsErrors__your_badge_could_not_be_added)) - .addAction( - NotificationCompat.Action.Builder( - null, - context.getString(R.string.Subscription__contact_support), - PendingIntent.getActivity( - context, - 0, - AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX), - if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0 - ) - ).build() - ) - .build() - - NotificationManagerCompat - .from(context) - .notify(NotificationIds.SUBSCRIPTION_VERIFY_FAILED, notification) - } - } - - abstract fun show(context: Context) -} diff --git a/app/src/main/res/drawable/ic_card_process.xml b/app/src/main/res/drawable/ic_card_process.xml new file mode 100644 index 0000000000..16933f5cb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_card_process.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/splash_image.xml b/app/src/main/res/layout/splash_image.xml new file mode 100644 index 0000000000..512f0ce717 --- /dev/null +++ b/app/src/main/res/layout/splash_image.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_badges.xml b/app/src/main/res/navigation/manage_badges.xml index 94868e6c82..ef180c6b7a 100644 --- a/app/src/main/res/navigation/manage_badges.xml +++ b/app/src/main/res/navigation/manage_badges.xml @@ -36,5 +36,10 @@ app:argType="org.thoughtcrime.securesms.badges.models.Badge" app:nullable="false" /> + + \ 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 caf7aa6dd2..2f2fd4ed6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,8 @@ https://support.signal.org/ https://signal.org/legal https://support.signal.org/hc/articles/4408365318426 + https://pay.google.com + https://support.signal.org/hc/articles/4408365318426#errors Yes No @@ -4069,10 +4071,17 @@ Become a Sustainer Add a Boost Not now - Your Sustainer subscription was automatically cancelled because you were inactive for too long. Your %1$s badge is no longer visible on your profile. + + Your Sustainer subscription was automatically cancelled because you were inactive for too long. Your %1$s badge is no longer visible on your profile. + + Your Sustainer subscription was cancelled because we couldn\'t process your payment. Your badge is no longer visible on your profile. You can keep using Signal but to support the app and reactivate your badge, renew now. Renew subscription + Can\'t process subscription payment + We\'re having trouble collecting your Signal Sustainer payment. Make sure your payment method is up to date. If it isn\'t, update it in Google Pay. Signal will try to process the payment again in a few days. + Don\'t show this again + Please contact support for more information. Contact Support Earn a %1$s badge @@ -4098,6 +4107,35 @@ Thank you for your donation. Your contribution helps fuel the mission of developing open source privacy technology that protects free expression and enables secure global communication for millions around the world. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840. No goods or services were provided in exchange for this donation. Please retain this receipt for your tax records. + + Try another payment method or contact your bank for more information. + + Verify your payment method is up to date in Google Pay and try again. + + Learn more + + Verify your payment method is up to date in Google Pay and try again. If the problem continues, contact your bank. + + Your card does not support this type of purchase. Try another payment method. + + Your card has expired. Update your payment method in Google Pay and try again. + + Go to Google Pay + + Your card number is incorrect. Update it in Google Pay and try again. + + Your card\'s CVC number is incorrect. Update it in Google Pay and try again. + + Your card does not have sufficient funds to complete this purchase. Try another payment method. + + The expiration month on your payment method is incorrect. Update it in Google Pay and try again. + + The expiration year on your payment method is incorrect. Update it in Google Pay and try again. + + Try completing the payment again or contact your bank for more information. + + Try again or contact your bank for more information. + Name your profile 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 3a9e52b021..ff26152c07 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -10,7 +10,6 @@ import okhttp3.Response import okio.ByteString import org.json.JSONObject import org.signal.core.util.money.FiatMoney -import java.io.IOException import java.math.BigDecimal import java.util.Locale @@ -90,7 +89,7 @@ class StripeApi( val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) } paymentMethodObject.getString("id") } else { - throw IOException("Failed to parse payment method response") + throw StripeError.FailedToParsePaymentMethodResponseError } } } @@ -128,19 +127,36 @@ class StripeApi( if (response.isSuccessful) { return response } else { - throw IOException("postForm failed with code: ${response.code()}. errorCode: ${parseErrorCode(response.body()?.string())}") + val body = response.body()?.toString() + throw StripeError.PostError( + response.code(), + parseErrorCode(body), + parseDeclineCode(body) + ) } } private fun parseErrorCode(body: String?): String? { if (body == null) { - return "No body." + return null } return try { JSONObject(body).getJSONObject("error").getString("code") } catch (e: Exception) { - "Unable to parse error code." + null + } + } + + private fun parseDeclineCode(body: String?): StripeDeclineCode? { + if (body == null) { + return null + } + + return try { + StripeDeclineCode.getFromCode(JSONObject(body).getJSONObject("error").getString("decline_code")) + } catch (e: Exception) { + null } } diff --git a/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt b/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt new file mode 100644 index 0000000000..e990960b98 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt @@ -0,0 +1,59 @@ +package org.signal.donations + +/** + * Stripe Payment Processor decline codes + */ +sealed class StripeDeclineCode { + + data class Known(val code: Code) : StripeDeclineCode() + data class Unknown(val code: String) : StripeDeclineCode() + + enum class Code(val code: String) { + AUTHENTICATION_REQUIRED("authentication_required"), + APPROVE_WITH_ID("approve_with_id"), + CALL_ISSUER("call_issuer"), + CARD_NOT_SUPPORTED("card_not_supported"), + CARD_VELOCITY_EXCEEDED("card_velocity_exceeded"), + CURRENCY_NOT_SUPPORTED("currency_not_supported"), + DO_NOT_HONOR("do_not_honor"), + DO_NOT_TRY_AGAIN("do_not_try_again"), + DUPLICATE_TRANSACTION("duplicate_transaction"), + EXPIRED_CARD("expired_card"), + FRAUDULENT("fraudulent"), + GENERIC_DECLINE("generic_decline"), + INCORRECT_NUMBER("incorrect_number"), + INCORRECT_CVC("incorrect_cvc"), + INSUFFICIENT_FUNDS("insufficient_funds"), + INVALID_ACCOUNT("invalid_account"), + INVALID_AMOUNT("invalid_amount"), + INVALID_CVC("invalid_cvc"), + INVALID_EXPIRY_MONTH("invalid_expiry_month"), + INVALID_EXPIRY_YEAR("invalid_expiry_year"), + INVALID_NUMBER("invalid_number"), + ISSUER_NOT_AVAILABLE("issuer_not_available"), + LOST_CARD("lost_card"), + MERCHANT_BLACKLIST("merchant_blacklist"), + NEW_ACCOUNT_INFORMATION_AVAILABLE("new_account_information_available"), + NO_ACTION_TAKEN("no_action_taken"), + NOT_PERMITTED("not_permitted"), + PROCESSING_ERROR("processing_error"), + REENTER_TRANSACTION("reenter_transaction"), + RESTRICTED_CARD("restricted_card"), + REVOCATION_OF_ALL_AUTHORIZATIONS("revocation_of_all_authorizations"), + REVOCATION_OF_AUTHORIZATION("revocation_of_authorization"), + SECURITY_VIOLATION("security_violation"), + SERVICE_NOT_ALLOWED("service_not_allowed"), + STOLEN_CARD("stolen_card"), + STOP_PAYMENT_ORDER("stop_payment_order"), + TRANSACTION_NOT_ALLOWED("transaction_not_allowed"), + TRY_AGAIN_LATER("try_again_later"), + WITHDRAWAL_COUNT_LIMIT_EXCEEDED("withdrawal_count_limit_exceeded") + } + + companion object { + fun getFromCode(code: String): StripeDeclineCode { + val typedCode: Code? = Code.values().firstOrNull { it.code == code } + return typedCode?.let { Known(typedCode) } ?: Unknown(code) + } + } +} diff --git a/donations/lib/src/main/java/org/signal/donations/StripeError.kt b/donations/lib/src/main/java/org/signal/donations/StripeError.kt new file mode 100644 index 0000000000..3223542cda --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/StripeError.kt @@ -0,0 +1,6 @@ +package org.signal.donations + +sealed class StripeError(message: String) : Exception(message) { + object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response") + class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode") +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 9e8fc9d95a..b8584e5957 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -16,7 +16,6 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; -import org.whispersystems.signalservice.internal.push.DonationIntentResult; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import java.io.IOException;