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

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

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

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