mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 10:17:56 +00:00
Add better error handling for subscriptions.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>?
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() { }
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ public final class StorageSyncHelper {
|
||||
|
||||
if (update.getNew().isSubscriptionManuallyCancelled()) {
|
||||
SignalStore.donationsValues().markUserManuallyCancelled();
|
||||
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(null);
|
||||
} else {
|
||||
SignalStore.donationsValues().clearUserManuallyCancelled();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user