Add better error handling for subscriptions.

This commit is contained in:
Alex Hart
2022-02-10 14:26:59 -04:00
committed by GitHub
parent ae0d6b5926
commit 5a6d77bae4
35 changed files with 978 additions and 247 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
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()
}
.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()
}
)
}
private fun startAnimationAboveSelectedBoost(view: View) {

View File

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

View File

@@ -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, Subject<DonationError>> = DonationErrorSource.values().associate { source ->
source to PublishSubject.create()
}
@JvmStatic
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
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<DonationError> = 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)
}
}

View File

@@ -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<Unit> {
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = android.R.string.cancel,
action = {}
)
}
override fun onOk(context: Context): DonationErrorParams.ErrorAction<Unit>? {
return DonationErrorParams.ErrorAction(
label = android.R.string.ok,
action = {}
)
}
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<Unit>? {
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<Unit>? {
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<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.Subscription__contact_support,
action = {
context.startActivity(AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX))
}
)
}
open fun onDialogDismissed() = Unit
}
}

View File

@@ -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<PendingIntent>) {
addAction(
NotificationCompat.Action.Builder(
null,
context.getString(errorAction.label),
errorAction.action.invoke()
).build()
)
}
private object NotificationCallback : DonationErrorParams.Callback<PendingIntent> {
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onOk(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
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<PendingIntent> {
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<PendingIntent> {
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<PendingIntent> {
return DonationErrorParams.ErrorAction(
label = label,
action = {
PendingIntent.getActivity(
context,
0,
actionIntent,
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0
)
}
)
}
}
}

View File

@@ -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<V> private constructor(
@StringRes val title: Int,
@StringRes val message: Int,
val positiveAction: ErrorAction<V>?,
val negativeAction: ErrorAction<V>?
) {
class ErrorAction<V>(
@StringRes val label: Int,
val action: () -> V
)
companion object {
fun <V> create(
context: Context,
throwable: Throwable?,
callback: Callback<V>
): DonationErrorParams<V> {
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 <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
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 <V> getLearnMoreParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = message,
positiveAction = callback.onOk(context),
negativeAction = callback.onLearnMore(context)
)
}
private fun <V> getGoToGooglePayParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = message,
positiveAction = callback.onGoToGooglePay(context),
negativeAction = callback.onCancel(context)
)
}
}
interface Callback<V> {
fun onOk(context: Context): ErrorAction<V>?
fun onCancel(context: Context): ErrorAction<V>?
fun onLearnMore(context: Context): ErrorAction<V>?
fun onContactSupport(context: Context): ErrorAction<V>?
fun onGoToGooglePay(context: Context): ErrorAction<V>?
}
}

View File

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

View File

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

View File

@@ -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()))
Log.w(TAG, "onPaymentError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
.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()
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
}
.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()
}
)
}
private fun onSubscriptionCancelled() {

View File

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

View File

@@ -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<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.splashImageResId == splashImageResId
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val splashImageView: ImageView = itemView as ImageView
override fun bind(model: Model) {
splashImageView.setImageResource(model.splashImageResId)
}
}
}

View File

@@ -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;
@@ -360,12 +362,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
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);
}
}

View File

@@ -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<ReceiptCredentialResponse> response) throws Exception {
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> 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);

View File

@@ -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<DonationReceiptRedemptionJob> {
@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);
}
}
}

View File

@@ -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<ActiveSubscription> 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);
}

View File

@@ -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());
if (status == null) {
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(getErrorSource()));
} else {
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status);
MultiDeviceSubscriptionSyncRequestJob.enqueue();
}
}
private void onAlreadyRedeemed(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
private void onAlreadyRedeemed(ServiceResponse<ReceiptCredentialResponse> 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

View File

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

View File

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

View File

@@ -161,6 +161,7 @@ public final class StorageSyncHelper {
if (update.getNew().isSubscriptionManuallyCancelled()) {
SignalStore.donationsValues().markUserManuallyCancelled();
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(null);
} else {
SignalStore.donationsValues().clearUserManuallyCancelled();
}

View File

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

View File

@@ -0,0 +1,32 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="86dp"
android:viewportWidth="108"
android:viewportHeight="86">
<path
android:pathData="M8.4,28.3L83.7,16.3C86.7,15.8 89.4,17.8 89.9,20.8L96.7,64C97.2,67 95.2,69.7 92.2,70.2L17,82.2C14,82.7 11.3,80.7 10.8,77.7L3.9,34.5C3.4,31.5 5.4,28.7 8.4,28.3Z"
android:fillColor="#DDE3E9"/>
<path
android:pathData="M89.8,20.7C89.3,17.7 86.5,15.7 83.6,16.2L60.2,20L42.6,78.2L92.3,70.3C95.3,69.8 97.3,67 96.8,64.1L89.8,20.7Z"
android:fillColor="#C5CFD6"/>
<path
android:pathData="M15.1,50.8L23.9,49.4C25.4,49.2 26.8,50.2 27,51.7L28.4,60.5C28.6,62 27.6,63.4 26.1,63.6L17.3,65C15.8,65.2 14.4,64.2 14.2,62.7L12.8,53.9C12.6,52.4 13.6,51 15.1,50.8Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M55.113,63.665L15.415,70.004L16.283,75.435L55.98,69.096L55.113,63.665Z"
android:fillColor="#969DAE"/>
<path
android:pathData="M84.623,23.267L44.925,29.606L45.793,35.038L85.49,28.698L84.623,23.267Z"
android:strokeAlpha="0.4"
android:fillColor="#969DAE"
android:fillAlpha="0.4"/>
<path
android:pathData="M67.924,61.687L59.827,62.98L60.694,68.411L68.791,67.118L67.924,61.687Z"
android:fillColor="#969DAE"/>
<path
android:pathData="M89.6,34.8C98.658,34.8 106,27.458 106,18.4C106,9.343 98.658,2 89.6,2C80.543,2 73.2,9.343 73.2,18.4C73.2,27.458 80.543,34.8 89.6,34.8Z"
android:fillColor="#FFD624"/>
<path
android:pathData="M88.2,21.8H91.5L91.8,10.6H87.9L88.2,21.8ZM89.9,27.6C91.1,27.6 92,26.8 92,25.8C92,24.8 91.1,24 89.9,24C88.7,24 87.8,24.8 87.8,25.8C87.7,26.8 88.7,27.6 89.9,27.6Z"
android:fillColor="#1B1B1B"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatImageView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:srcCompat="@drawable/ic_card_process" />

View File

@@ -36,5 +36,10 @@
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
app:nullable="false" />
<argument
android:name="cancelation_reason"
app:argType="string"
app:nullable="true" />
</dialog>
</navigation>

View File

@@ -9,6 +9,8 @@
<string name="support_center_url" translatable="false">https://support.signal.org/</string>
<string name="terms_and_privacy_policy_url" translatable="false">https://signal.org/legal</string>
<string name="sustainer_boost_and_badges" translatable="false">https://support.signal.org/hc/articles/4408365318426</string>
<string name="google_pay_url" translatable="false">https://pay.google.com</string>
<string name="donation_decline_code_error_url" translatable="false">https://support.signal.org/hc/articles/4408365318426#errors</string>
<string name="yes">Yes</string>
<string name="no">No</string>
@@ -4069,10 +4071,17 @@
<string name="ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer">Become a Sustainer</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__add_a_boost">Add a Boost</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__not_now">Not now</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__your_sustainer">Your Sustainer subscription was automatically cancelled because you were inactive for too long. Your %1$s badge is no longer visible on your profile.</string>
<!-- Copy displayed when badge expires after user inactivity -->
<string name="ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_automatically">Your Sustainer subscription was automatically cancelled because you were inactive for too long. Your %1$s badge is no longer visible on your profile.</string>
<!-- Copy displayed when badge expires after payment failure -->
<string name="ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_canceled">Your Sustainer subscription was cancelled because we couldn\'t process your payment. Your badge is no longer visible on your profile.</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__you_can">You can keep using Signal but to support the app and reactivate your badge, renew now.</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__renew_subscription">Renew subscription</string>
<string name="CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment">Can\'t process subscription payment</string>
<string name="CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble">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.</string>
<string name="CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again">Don\'t show this again</string>
<string name="Subscription__please_contact_support_for_more_information">Please contact support for more information.</string>
<string name="Subscription__contact_support">Contact Support</string>
<string name="Subscription__earn_a_s_badge">Earn a %1$s badge</string>
@@ -4098,6 +4107,35 @@
<string name="Boost__thank_you_for_your_donation" translatable="false">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.</string>
<!-- Stripe decline code generic_failure -->
<string name="DeclineCode__try_another_payment_method_or_contact_your_bank">Try another payment method or contact your bank for more information.</string>
<!-- Stripe decline code verify on Google Pay and try again -->
<string name="DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again">Verify your payment method is up to date in Google Pay and try again.</string>
<!-- Stripe decline code learn more action label -->
<string name="DeclineCode__learn_more">Learn more</string>
<!-- Stripe decline code contact issuer -->
<string name="DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem">Verify your payment method is up to date in Google Pay and try again. If the problem continues, contact your bank.</string>
<!-- Stripe decline code purchase not supported -->
<string name="DeclineCode__your_card_does_not_support_this_type_of_purchase">Your card does not support this type of purchase. Try another payment method.</string>
<!-- Stripe decline code your card has expired -->
<string name="DeclineCode__your_card_has_expired">Your card has expired. Update your payment method in Google Pay and try again.</string>
<!-- Stripe decline code go to google pay action label -->
<string name="DeclineCode__go_to_google_pay">Go to Google Pay</string>
<!-- Stripe decline code incorrect card number -->
<string name="DeclineCode__your_card_number_is_incorrect">Your card number is incorrect. Update it in Google Pay and try again.</string>
<!-- Stripe decline code incorrect cvc -->
<string name="DeclineCode__your_cards_cvc_number_is_incorrect">Your card\'s CVC number is incorrect. Update it in Google Pay and try again.</string>
<!-- Stripe decline code insufficient funds -->
<string name="DeclineCode__your_card_does_not_have_sufficient_funds">Your card does not have sufficient funds to complete this purchase. Try another payment method.</string>
<!-- Stripe decline code incorrect expiration month -->
<string name="DeclineCode__the_expiration_month">The expiration month on your payment method is incorrect. Update it in Google Pay and try again.</string>
<!-- Stripe decline code incorrect expiration year -->
<string name="DeclineCode__the_expiration_year">The expiration year on your payment method is incorrect. Update it in Google Pay and try again.</string>
<!-- Stripe decline code issuer not available -->
<string name="DeclineCode__try_completing_the_payment_again">Try completing the payment again or contact your bank for more information.</string>
<!-- Stripe decline code processing error -->
<string name="DeclineCode__try_again">Try again or contact your bank for more information.</string>
<!-- Title of create notification profile screen -->
<string name="EditNotificationProfileFragment__name_your_profile">Name your profile</string>
<!-- Hint text for create/edit notification profile name -->

View File

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

View File

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

View File

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

View File

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