Rewrite in-app-payment flows to prepare for backups support.

This commit is contained in:
Alex Hart
2024-04-19 17:04:15 -03:00
committed by Cody Henthorne
parent b36b00a11c
commit d719edf104
123 changed files with 5429 additions and 1586 deletions

View File

@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -55,8 +55,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.MONTHLY)
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.ONE_TIME)
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(InAppPaymentTable.Type.RECURRING_DONATION)
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(InAppPaymentTable.Type.ONE_TIME_DONATION)
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()

View File

@@ -25,8 +25,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
@@ -289,7 +291,8 @@ class AppSettingsFragment : DSLSettingsFragment(
}
private fun copySubscriberIdToClipboard(): Boolean {
val subscriber = SignalStore.donationsValues().getSubscriber()
// TODO [alex] -- db access on main thread!
val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
return if (subscriber == null) {
false
} else {

View File

@@ -6,8 +6,9 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
@@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class AppSettingsViewModel(
monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService())
) : ViewModel() {
private val store = Store(
@@ -39,7 +40,7 @@ class AppSettingsViewModel(
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.map { it.unreadCount }.orElse(0)) }
store.update(selfLiveData) { self, state -> state.copy(self = self) }
disposables += monthlyDonationRepository.getActiveSubscription().subscribeBy(
disposables += recurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION).subscribeBy(
onSuccess = { activeSubscription ->
store.update { state ->
state.copy(allowUserToGoToDonationManagementScreen = activeSubscription.isActive || InAppDonations.hasAtLeastOnePaymentMethodAvailable())

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.privacy.advanced.AdvancedPrivacySettingsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
@@ -33,11 +34,12 @@ import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.MegaphoneDatabase
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
@@ -45,8 +47,6 @@ import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -63,7 +63,7 @@ import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
@@ -538,7 +538,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
if (SignalStore.donationsValues().getSubscriber() != null) {
// TODO [alex] -- db access on main thread!
if (InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) != null) {
sectionHeaderPref(DSLSettingsText.from("Badges"))
clickPref(
@@ -571,7 +572,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
}
)
dividerPref()
}
@@ -938,16 +938,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
-1L,
TerminalDonationQueue.TerminalDonation(
level = 1000
)
).enqueue()
viewModel.enqueueSubscriptionRedemption()
}
private fun enqueueSubscriptionKeepAlive() {
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
}
private fun clearCdsHistory() {

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
import android.content.Context
import org.json.JSONObject
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
@@ -13,6 +14,7 @@ import org.thoughtcrime.securesms.emoji.EmojiFiles
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.FetchRemoteMegaphoneImageJob
import org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.recipients.Recipient
@@ -30,6 +32,15 @@ class InternalSettingsRepository(context: Context) {
}
}
fun enqueueSubscriptionRedemption() {
SignalExecutors.BOUNDED.execute {
val latest = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(InAppPaymentTable.Type.RECURRING_DONATION)
if (latest != null) {
InAppPaymentRecurringContextJob.createJobChain(latest).enqueue()
}
}
}
fun addSampleReleaseNote() {
SignalExecutors.UNBOUNDED.execute {
ApplicationDependencies.getJobManager().runSynchronously(CreateReleaseChannelJob.create(), 5000)

View File

@@ -136,6 +136,10 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
repository.addRemoteMegaphone(RemoteMegaphoneRecord.ActionId.DONATE_FOR_FRIEND)
}
fun enqueueSubscriptionRedemption() {
repository.enqueueSubscriptionRedemption()
}
fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
}

View File

@@ -14,8 +14,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Un
import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
@@ -101,7 +101,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
fun save(): Completable {
val snapshot = store.state
val saveState = Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
synchronized(InAppPaymentSubscriberRecord.Type.DONATION) {
when {
snapshot.selectedBadge?.isGift() == true -> handleGiftExpiration(snapshot)
snapshot.selectedBadge?.isBoost() == true -> handleBoostExpiration(snapshot)
@@ -116,7 +116,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
fun clearErrorState(): Completable {
return Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
synchronized(InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.donationsValues().setExpiredBadge(null)
SignalStore.donationsValues().setExpiredGiftBadge(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null

View File

@@ -30,9 +30,9 @@ import org.signal.core.ui.Buttons
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
@@ -47,7 +47,7 @@ class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
DonationPendingBottomSheetContent(
badge = args.request.badge,
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
onDoneClick = this::onDoneClick
)
}
@@ -59,7 +59,7 @@ class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (args.request.donateToSignalType == DonateToSignalType.ONE_TIME) {
if (!args.inAppPayment.type.recurring) {
findNavController().popBackStack()
} else {
requireActivity().finish()

View File

@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FeatureFlags
@@ -24,21 +24,23 @@ object InAppDonations {
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() || isIDEALAvailable()
}
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, inAppPaymentType: InAppPaymentTable.Type): Boolean {
return when (paymentSourceType) {
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(inAppPaymentType)
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(inAppPaymentType)
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(inAppPaymentType)
PaymentSourceType.Unknown -> false
}
}
private fun isPayPalAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return when (donateToSignalType) {
DonateToSignalType.ONE_TIME, DonateToSignalType.GIFT -> FeatureFlags.paypalOneTimeDonations()
DonateToSignalType.MONTHLY -> FeatureFlags.paypalRecurringDonations()
private fun isPayPalAvailableForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
return when (inAppPaymentType) {
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentTable.Type.ONE_TIME_DONATION, InAppPaymentTable.Type.ONE_TIME_GIFT -> FeatureFlags.paypalOneTimeDonations()
InAppPaymentTable.Type.RECURRING_DONATION -> FeatureFlags.paypalRecurringDonations()
InAppPaymentTable.Type.RECURRING_BACKUP -> FeatureFlags.messageBackups() && FeatureFlags.paypalRecurringDonations()
} && !LocaleFeatureFlags.isPayPalDisabled()
}
@@ -81,15 +83,15 @@ object InAppDonations {
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number
* and donation type.
*/
fun isSEPADebitAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && isSEPADebitAvailable()
fun isSEPADebitAvailableForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
return inAppPaymentType != InAppPaymentTable.Type.ONE_TIME_GIFT && isSEPADebitAvailable()
}
/**
* Whether the user is in a region which suports IDEAL transfers, based off local phone number and
* donation type
*/
fun isIDEALAvailbleForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && isIDEALAvailable()
fun isIDEALAvailbleForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
return inAppPaymentType != InAppPaymentTable.Type.ONE_TIME_GIFT && isIDEALAvailable()
}
}

View File

@@ -0,0 +1,566 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription
import android.annotation.SuppressLint
import androidx.annotation.WorkerThread
import com.squareup.wire.get
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
import java.security.SecureRandom
import java.util.Currency
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
/**
* Unifies legacy access and new access to in app payment data.
*/
object InAppPaymentsRepository {
private const val JOB_PREFIX = "InAppPayments__"
private val TAG = Log.tag(InAppPaymentsRepository::class.java)
private val temporaryErrorProcessor = PublishProcessor.create<Pair<InAppPaymentTable.InAppPaymentId, Throwable>>()
/**
* Wraps an in-app-payment update in a completable.
*/
fun updateInAppPayment(inAppPayment: InAppPaymentTable.InAppPayment): Completable {
return Completable.fromAction {
SignalDatabase.inAppPayments.update(inAppPayment)
}.subscribeOn(Schedulers.io())
}
/**
* Common logic for handling errors coming from the Rx chains that handle payments. These errors
* are analyzed and then either written to the database or dispatched to the temporary error processor.
*/
fun handlePipelineError(
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
donationErrorSource: DonationErrorSource,
paymentSourceType: PaymentSourceType,
error: Throwable
) {
if (error is InAppPaymentError) {
setErrorIfNotPresent(inAppPaymentId, error.inAppPaymentDataError)
return
}
val donationError: DonationError = when (error) {
is DonationError -> error
is DonationProcessorError -> error.toDonationError(donationErrorSource, paymentSourceType)
else -> DonationError.genericBadgeRedemptionFailure(donationErrorSource)
}
val inAppPaymentError = InAppPaymentError.fromDonationError(donationError)?.inAppPaymentDataError
if (inAppPaymentError != null) {
Log.w(TAG, "Detected a terminal error.")
setErrorIfNotPresent(inAppPaymentId, inAppPaymentError).subscribe()
} else {
Log.w(TAG, "Detected a temporary error.")
temporaryErrorProcessor.onNext(inAppPaymentId to donationError)
}
}
/**
* Observe a stream of "temporary errors". These are situations in which either the user cancelled out, opened an external application,
* or needs to wait a longer time period than 10s for the completion of their payment.
*/
fun observeTemporaryErrors(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<Pair<InAppPaymentTable.InAppPaymentId, Throwable>> {
return temporaryErrorProcessor.filter { (id, _) -> id == inAppPaymentId }
}
/**
* Writes the given error to the database, if and only if there is not already an error set.
*/
private fun setErrorIfNotPresent(inAppPaymentId: InAppPaymentTable.InAppPaymentId, error: InAppPaymentData.Error?): Completable {
return Completable.fromAction {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
if (inAppPayment.data.error == null) {
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
notified = false,
state = InAppPaymentTable.State.END,
data = inAppPayment.data.copy(error = error)
)
)
}
}.subscribeOn(Schedulers.io())
}
/**
* Returns a Single that can give a snapshot of the given payment, and will throw if it is not found.
*/
fun requireInAppPayment(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Single<InAppPaymentTable.InAppPayment> {
return Single.fromCallable {
SignalDatabase.inAppPayments.getById(inAppPaymentId) ?: throw Exception("Not found.")
}.subscribeOn(Schedulers.io())
}
/**
* Returns a Flowable source of InAppPayments that emits whenever the payment with the given id is updated. This
* flowable is primed with the current state.
*/
fun observeUpdates(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<InAppPaymentTable.InAppPayment> {
return Flowable.create({ emitter ->
val observer = InAppPaymentObserver {
if (it.id == inAppPaymentId) {
emitter.onNext(it)
}
}
ApplicationDependencies.getDatabaseObserver().registerInAppPaymentObserver(observer)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
SignalDatabase.inAppPayments.getById(inAppPaymentId)?.also {
observer.onInAppPaymentChanged(it)
}
}, BackpressureStrategy.LATEST)
}
/**
* For one-time:
* - Each job chain is serialized with respect to the in-app-payment ID
*
* For recurring:
* - Each job chain is serialized with respect to the in-app-payment type
*/
fun resolveJobQueueKey(inAppPayment: InAppPaymentTable.InAppPayment): String {
return when (inAppPayment.type) {
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN.")
InAppPaymentTable.Type.ONE_TIME_GIFT, InAppPaymentTable.Type.ONE_TIME_DONATION -> "$JOB_PREFIX${inAppPayment.id.serialize()}"
InAppPaymentTable.Type.RECURRING_DONATION, InAppPaymentTable.Type.RECURRING_BACKUP -> "$JOB_PREFIX${inAppPayment.type.code}"
}
}
/**
* Returns a duration to utilize for jobs tied to different payment methods. For long running bank transfers, we need to
* allow extra time for completion.
*/
fun resolveContextJobLifespan(inAppPayment: InAppPaymentTable.InAppPayment): Duration {
return when (inAppPayment.data.paymentMethodType) {
InAppPaymentData.PaymentMethodType.SEPA_DEBIT, InAppPaymentData.PaymentMethodType.IDEAL -> 30.days
else -> 1.days
}
}
/**
* Returns the object to utilize as a mutex for recurring subscriptions.
*/
fun resolveMutex(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Any {
val payment = SignalDatabase.inAppPayments.getById(inAppPaymentId) ?: error("Not found")
return payment.type.requireSubscriberType()
}
/**
* Maps a payment type into a request code for grabbing a Google Pay token.
*/
fun getGooglePayRequestCode(inAppPaymentType: InAppPaymentTable.Type): Int {
return when (inAppPaymentType) {
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentTable.Type.ONE_TIME_GIFT -> 16143
InAppPaymentTable.Type.ONE_TIME_DONATION -> 16141
InAppPaymentTable.Type.RECURRING_DONATION -> 16142
InAppPaymentTable.Type.RECURRING_BACKUP -> 16144
}
}
/**
* Converts an error source to a persistable type. For types that don't map,
* UNKNOWN is returned.
*/
fun DonationErrorSource.toInAppPaymentType(): InAppPaymentTable.Type {
return when (this) {
DonationErrorSource.ONE_TIME -> InAppPaymentTable.Type.ONE_TIME_DONATION
DonationErrorSource.MONTHLY -> InAppPaymentTable.Type.RECURRING_DONATION
DonationErrorSource.GIFT -> InAppPaymentTable.Type.ONE_TIME_GIFT
DonationErrorSource.GIFT_REDEMPTION -> InAppPaymentTable.Type.UNKNOWN
DonationErrorSource.KEEP_ALIVE -> InAppPaymentTable.Type.UNKNOWN
DonationErrorSource.UNKNOWN -> InAppPaymentTable.Type.UNKNOWN
}
}
/**
* Converts the structured payment source type into a type we can write to the database.
*/
fun PaymentSourceType.toPaymentMethodType(): InAppPaymentData.PaymentMethodType {
return when (this) {
PaymentSourceType.PayPal -> InAppPaymentData.PaymentMethodType.PAYPAL
PaymentSourceType.Stripe.CreditCard -> InAppPaymentData.PaymentMethodType.CARD
PaymentSourceType.Stripe.GooglePay -> InAppPaymentData.PaymentMethodType.GOOGLE_PAY
PaymentSourceType.Stripe.IDEAL -> InAppPaymentData.PaymentMethodType.IDEAL
PaymentSourceType.Stripe.SEPADebit -> InAppPaymentData.PaymentMethodType.SEPA_DEBIT
PaymentSourceType.Unknown -> InAppPaymentData.PaymentMethodType.UNKNOWN
}
}
/**
* Converts the database payment method type to the structured sealed type
*/
fun InAppPaymentData.PaymentMethodType.toPaymentSourceType(): PaymentSourceType {
return when (this) {
InAppPaymentData.PaymentMethodType.PAYPAL -> PaymentSourceType.PayPal
InAppPaymentData.PaymentMethodType.CARD -> PaymentSourceType.Stripe.CreditCard
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
InAppPaymentData.PaymentMethodType.IDEAL -> PaymentSourceType.Stripe.IDEAL
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
InAppPaymentData.PaymentMethodType.UNKNOWN -> PaymentSourceType.Unknown
}
}
/**
* Converts network ChargeFailure objects into the form we can persist in the database.
*/
fun ActiveSubscription.ChargeFailure.toInAppPaymentDataChargeFailure(): InAppPaymentData.ChargeFailure {
return InAppPaymentData.ChargeFailure(
code = this.code ?: "",
message = this.message ?: "",
outcomeNetworkStatus = outcomeNetworkStatus ?: "",
outcomeNetworkReason = outcomeNetworkReason ?: "",
outcomeType = outcomeType ?: ""
)
}
/**
* Converts our database persistable ChargeFailure objects into the form we expect from the network.
*/
fun InAppPaymentData.ChargeFailure.toActiveSubscriptionChargeFailure(): ActiveSubscription.ChargeFailure {
return ActiveSubscription.ChargeFailure(
code,
message,
outcomeNetworkStatus,
outcomeNetworkReason,
outcomeType
)
}
/**
* Retrieves the latest payment method type, biasing the result towards what is available in the database and falling
* back on information in SignalStore. This information is utilized in some error presentation as well as in subscription
* updates.
*/
@WorkerThread
fun getLatestPaymentMethodType(subscriberType: InAppPaymentSubscriberRecord.Type): InAppPaymentData.PaymentMethodType {
val paymentMethodType = getSubscriber(subscriberType)?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN
return if (paymentMethodType != InAppPaymentData.PaymentMethodType.UNKNOWN) {
paymentMethodType
} else if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.donationsValues().getSubscriptionPaymentSourceType().toPaymentMethodType()
} else {
return InAppPaymentData.PaymentMethodType.UNKNOWN
}
}
/**
* Checks if the latest subscription was manually cancelled by the user. We bias towards what the database tells us and
* fall back on the SignalStore value (which is deprecated and will be removed in a future release)
*/
@JvmStatic
@WorkerThread
fun isUserManuallyCancelled(subscriberType: InAppPaymentSubscriberRecord.Type): Boolean {
val subscriber = getSubscriber(subscriberType) ?: return false
val latestSubscription = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(subscriber.type.inAppPaymentType)
return if (latestSubscription == null) {
SignalStore.donationsValues().isUserManuallyCancelled()
} else {
latestSubscription.data.cancellation?.reason == InAppPaymentData.Cancellation.Reason.MANUAL
}
}
/**
* Sets whether we should force a cancellation before our next subscription attempt. This is to help clean up
* bad state in some edge cases.
*/
@JvmStatic
@WorkerThread
fun setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber: InAppPaymentSubscriberRecord, shouldCancel: Boolean) {
if (subscriber.type == InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = shouldCancel
}
SignalDatabase.inAppPaymentSubscribers.setRequiresCancel(
subscriberId = subscriber.subscriberId,
requiresCancel = shouldCancel
)
}
/**
* Retrieves whether or not we should force a cancel before next subscribe attempt for in app payments of the given
* type. This method will first check the database, and then fall back on the deprecated SignalStore value.
*/
@JvmStatic
@WorkerThread
fun getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType: InAppPaymentSubscriberRecord.Type): Boolean {
val latestSubscriber = getSubscriber(subscriberType)
return latestSubscriber?.requiresCancel ?: if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt
} else {
false
}
}
/**
* Grabs a subscriber based off the type and currency
*/
@JvmStatic
@Suppress("DEPRECATION")
@SuppressLint("DiscouragedApi")
@WorkerThread
fun getSubscriber(currency: Currency, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
val subscriber = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode(currency.currencyCode, type)
return if (subscriber == null && type == InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.donationsValues().getSubscriber(currency)
} else {
subscriber
}
}
/**
* Grabs the "active" subscriber according to the selected currency in the value store.
*/
@JvmStatic
@WorkerThread
fun getSubscriber(type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
val currency = SignalStore.donationsValues().getSubscriptionCurrency(type)
return getSubscriber(currency, type)
}
/**
* Gets a non-null subscriber for the given type, or throws.
*/
@JvmStatic
@WorkerThread
fun requireSubscriber(type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord {
return requireNotNull(getSubscriber(type))
}
/**
* Sets the subscriber, writing them to the database.
*/
@JvmStatic
@WorkerThread
fun setSubscriber(subscriber: InAppPaymentSubscriberRecord) {
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber)
}
/**
* Checks whether or not a pending donation exists either in the database or via the legacy job watcher.
*/
@WorkerThread
fun hasPendingDonation(): Boolean {
return SignalDatabase.inAppPayments.hasPendingDonation() || DonationRedemptionJobWatcher.hasPendingRedemptionJob()
}
/**
* Emits a stream of status updates for donations of the given type. Only One-time donations and recurring donations are currently supported.
*/
fun observeInAppPaymentRedemption(type: InAppPaymentTable.Type): Observable<DonationRedemptionJobStatus> {
val jobStatusObservable: Observable<DonationRedemptionJobStatus> = when (type) {
InAppPaymentTable.Type.UNKNOWN -> Observable.empty()
InAppPaymentTable.Type.ONE_TIME_GIFT -> Observable.empty()
InAppPaymentTable.Type.ONE_TIME_DONATION -> DonationRedemptionJobWatcher.watchOneTimeRedemption()
InAppPaymentTable.Type.RECURRING_DONATION -> DonationRedemptionJobWatcher.watchSubscriptionRedemption()
InAppPaymentTable.Type.RECURRING_BACKUP -> Observable.empty()
}
val fromDatabase: Observable<DonationRedemptionJobStatus> = Observable.create { emitter ->
val observer = InAppPaymentObserver {
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
emitter.onNext(Optional.ofNullable(latestInAppPayment))
}
ApplicationDependencies.getDatabaseObserver().registerInAppPaymentObserver(observer)
emitter.setCancellable { ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) }
}.switchMap { inAppPaymentOptional ->
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap jobStatusObservable
val value = when (inAppPayment.state) {
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION -> {
DonationRedemptionJobStatus.PendingExternalVerification(
pendingOneTimeDonation = inAppPayment.toPendingOneTimeDonation(),
nonVerifiedMonthlyDonation = inAppPayment.toNonVerifiedMonthlyDonation()
)
}
InAppPaymentTable.State.PENDING -> {
if (inAppPayment.data.redemption?.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED) {
DonationRedemptionJobStatus.PendingReceiptRedemption
} else {
DonationRedemptionJobStatus.PendingReceiptRequest
}
}
InAppPaymentTable.State.END -> {
if (type.recurring && inAppPayment.data.error != null) {
DonationRedemptionJobStatus.FailedSubscription
} else {
DonationRedemptionJobStatus.None
}
}
}
Observable.just(value)
}
return fromDatabase
.switchMap {
if (it == DonationRedemptionJobStatus.None) {
jobStatusObservable
} else {
Observable.just(it)
}
}
.distinctUntilChanged()
}
private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? {
if (type.recurring) {
return null
}
return PendingOneTimeDonation(
paymentMethodType = when (data.paymentMethodType) {
InAppPaymentData.PaymentMethodType.UNKNOWN -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.CARD -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
InAppPaymentData.PaymentMethodType.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
InAppPaymentData.PaymentMethodType.PAYPAL -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
},
amount = data.amount!!,
badge = data.badge!!,
timestamp = insertedAt.inWholeMilliseconds,
error = null,
pendingVerification = true,
checkedVerification = data.waitForAuth!!.checkedVerification
)
}
private fun InAppPaymentTable.InAppPayment.toNonVerifiedMonthlyDonation(): NonVerifiedMonthlyDonation? {
if (!type.recurring) {
return null
}
return NonVerifiedMonthlyDonation(
timestamp = insertedAt.inWholeMilliseconds,
price = data.amount!!.toFiatMoney(),
level = data.level.toInt(),
checkedVerification = data.waitForAuth!!.checkedVerification
)
}
/**
* Generates a new request credential that can be used to retrieve a presentation that can be submitted to get a badge or backup.
*/
fun generateRequestCredential(): ReceiptCredentialRequestContext {
Log.d(TAG, "Generating request credentials context for token redemption...", true)
val secureRandom = SecureRandom()
val randomBytes = Util.getSecretBytes(ReceiptSerial.SIZE)
return try {
val receiptSerial = ReceiptSerial(randomBytes)
val operations = ApplicationDependencies.getClientZkReceiptOperations()
operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial)
} catch (e: InvalidInputException) {
Log.e(TAG, "Failed to create credential.", e)
throw AssertionError(e)
} catch (e: VerificationFailedException) {
Log.e(TAG, "Failed to create credential.", e)
throw AssertionError(e)
}
}
/**
* Common logic for building failures based off payment failure state.
*/
fun buildPaymentFailure(inAppPayment: InAppPaymentTable.InAppPayment, chargeFailure: ActiveSubscription.ChargeFailure?): InAppPaymentData.Error {
val builder = InAppPaymentData.Error.Builder()
if (chargeFailure == null) {
builder.type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING
return builder.build()
}
val donationProcessor = inAppPayment.data.paymentMethodType.toDonationProcessor()
if (donationProcessor == DonationProcessor.PAYPAL) {
builder.type = InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR
builder.data_ = chargeFailure.code
} else {
val declineCode = StripeDeclineCode.getFromCode(chargeFailure.outcomeNetworkReason)
val failureCode = StripeFailureCode.getFromCode(chargeFailure.code)
if (failureCode.isKnown) {
builder.type = InAppPaymentData.Error.Type.STRIPE_FAILURE
builder.data_ = failureCode.toString()
} else if (declineCode.isKnown()) {
builder.type = InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR
builder.data_ = declineCode.toString()
} else {
builder.type = InAppPaymentData.Error.Type.STRIPE_CODED_ERROR
builder.data_ = chargeFailure.code
}
}
return builder.build()
}
/**
* Converts a payment method type into the processor that manages it, either Stripe or PayPal.
*/
fun InAppPaymentData.PaymentMethodType.toDonationProcessor(): DonationProcessor {
return when (this) {
InAppPaymentData.PaymentMethodType.UNKNOWN -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.CARD -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.IDEAL -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.PAYPAL -> DonationProcessor.PAYPAL
}
}
}

View File

@@ -7,31 +7,29 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.jobs.InAppPaymentOneTimeContextJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.DonationProcessor
import java.util.Currency
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class OneTimeDonationRepository(private val donationsService: DonationsService) {
class OneTimeInAppPaymentRepository(private val donationsService: DonationsService) {
companion object {
private val TAG = Log.tag(OneTimeDonationRepository::class.java)
private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java)
fun <T : Any> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
return if (throwable is DonationError) {
@@ -50,7 +48,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
if (recipient.isSelf) {
Log.d(TAG, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
@@ -93,20 +91,25 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
}
fun waitForOneTimeRedemption(
gatewayRequest: GatewayRequest,
inAppPayment: InAppPaymentTable.InAppPayment,
paymentIntentId: String,
donationProcessor: DonationProcessor,
paymentSourceType: PaymentSourceType
): Completable {
val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit
val isBoost = gatewayRequest.recipientId == Recipient.self().id
val isBoost = inAppPayment.data.recipientId?.let { RecipientId.from(it) } == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
val waitOnRedemption = Completable.create {
val timeoutError: DonationError = if (isLongRunning) {
BadgeRedemptionError.DonationPending(donationErrorSource, inAppPayment)
} else {
BadgeRedemptionError.TimeoutWaitingForTokenError(donationErrorSource)
}
return Single.fromCallable {
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(gatewayRequest.fiat)
DonationReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
} else {
DonationReceiptRecord.createForGift(gatewayRequest.fiat)
DonationReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
}
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
@@ -114,66 +117,30 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
SignalStore.donationsValues().setPendingOneTimeDonation(
DonationSerializationHelper.createPendingOneTimeDonationProto(
gatewayRequest.badge,
paymentSourceType,
gatewayRequest.fiat
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
data = inAppPayment.data.copy(
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT,
paymentIntentId = paymentIntentId
)
)
)
)
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue()
inAppPayment.id
}.flatMap { inAppPaymentId ->
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
InAppPaymentsRepository.observeUpdates(inAppPaymentId).filter {
it.state == InAppPaymentTable.State.END
}.take(1).firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError))
}.map {
if (it.data.error != null) {
Log.d(TAG, "Failure during redemption chain.", true)
throw DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
chain.enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
val timeoutError: DonationError = if (isLongRunning) {
DonationError.donationPending(donationErrorSource, gatewayRequest)
} else {
DonationError.timeoutWaitingForToken(donationErrorSource)
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
}
else -> {
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(timeoutError)
}
}
} else {
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(timeoutError)
}
} catch (e: InterruptedException) {
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(timeoutError)
}
}
return waitOnRedemption
it
}.ignoreElement()
}
}

View File

@@ -7,7 +7,9 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse
@@ -29,7 +31,7 @@ class PayPalRepository(private val donationsService: DonationsService) {
private val TAG = Log.tag(PayPalRepository::class.java)
}
private val monthlyDonationRepository = MonthlyDonationRepository(donationsService)
private val recurringInAppPaymentRepository = RecurringInAppPaymentRepository(donationsService)
fun createOneTimePaymentIntent(
amount: FiatMoney,
@@ -48,7 +50,7 @@ class PayPalRepository(private val donationsService: DonationsService) {
)
}
.flatMap { it.flattenResult() }
.onErrorResumeNext { OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
.onErrorResumeNext { OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
.subscribeOn(Schedulers.io())
}
@@ -76,34 +78,42 @@ class PayPalRepository(private val donationsService: DonationsService) {
* it means that the PaymentMethod is already tied to a Stripe account. We can retry in this
* situation by simply deleting the old subscriber id on the service and replacing it.
*/
fun createPaymentMethod(retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
return Single.fromCallable {
donationsService.createPayPalPaymentMethod(
Locale.getDefault(),
SignalStore.donationsValues().requireSubscriber().subscriberId,
InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId,
MONTHLY_RETURN_URL,
CANCEL_URL
)
}.flatMap { serviceResponse ->
if (retryOn409 && serviceResponse.status == 409) {
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false))
recurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, retryOn409 = false))
} else {
serviceResponse.flattenResult()
}
}.subscribeOn(Schedulers.io())
}
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable {
Log.d(TAG, "Setting default payment method...", true)
donationsService.setDefaultPayPalPaymentMethod(
SignalStore.donationsValues().requireSubscriber().subscriberId,
paymentMethodId
)
}.flatMap { it.flattenResult() }.ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method.", true)
Log.d(TAG, "Storing the subscription payment source type locally.", true)
SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
}.subscribeOn(Schedulers.io())
fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String): Completable {
return Single
.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
.flatMapCompletable { subscriberRecord ->
Single.fromCallable {
Log.d(TAG, "Setting default payment method...", true)
donationsService.setDefaultPayPalPaymentMethod(
subscriberRecord.subscriberId,
paymentMethodId
)
}.flatMap { it.flattenResult() }.ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method.", true)
Log.d(TAG, "Storing the subscription payment source type locally.", true)
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(
subscriberRecord.subscriberId,
InAppPaymentData.PaymentMethodType.PAYPAL
)
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -4,23 +4,24 @@ import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
import org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
@@ -29,26 +30,24 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class MonthlyDonationRepository(private val donationsService: DonationsService) {
class RecurringInAppPaymentRepository(private val donationsService: DonationsService) {
private val TAG = Log.tag(MonthlyDonationRepository::class.java)
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single<ActiveSubscription> {
val localSubscription = InAppPaymentsRepository.getSubscriber(type)
return if (localSubscription != null) {
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
.doOnSuccess { activeSubscription ->
if (activeSubscription.isActive && activeSubscription.activeSubscription.endOfCurrentPeriod > SignalStore.donationsValues().getLastEndOfPeriod()) {
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
}
}
} else {
@@ -85,23 +84,23 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
* Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID
* in case of failures.
*/
fun rotateSubscriberId(): Completable {
fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true)
val cancelCompletable: Completable = if (SignalStore.donationsValues().getSubscriber() != null) {
cancelActiveSubscription().andThen(updateLocalSubscriptionStateAndScheduleDataSync())
val cancelCompletable: Completable = if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) {
cancelActiveSubscription(subscriberType).andThen(updateLocalSubscriptionStateAndScheduleDataSync(subscriberType))
} else {
Completable.complete()
}
return cancelCompletable.andThen(ensureSubscriberId(isRotation = true))
return cancelCompletable.andThen(ensureSubscriberId(subscriberType, isRotation = true))
}
fun ensureSubscriberId(isRotation: Boolean = false): Completable {
fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false): Completable {
Log.d(TAG, "Ensuring SubscriberId exists on Signal service {isRotation?$isRotation}...", true)
val subscriberId: SubscriberId = if (isRotation) {
SubscriberId.generate()
} else {
SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate()
}
return Single
@@ -113,20 +112,27 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
.doOnComplete {
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
SignalStore
.donationsValues()
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
InAppPaymentsRepository.setSubscriber(
InAppPaymentSubscriberRecord(
subscriberId = subscriberId,
currencyCode = SignalStore.donationsValues().getSubscriptionCurrency(subscriberType).currencyCode,
type = subscriberType,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN
)
)
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun cancelActiveSubscription(): Completable {
fun cancelActiveSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
Log.d(TAG, "Canceling active subscription...", true)
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return Single
.fromCallable {
val localSubscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
donationsService.cancelSubscription(localSubscriber.subscriberId)
}
.subscribeOn(Schedulers.io())
@@ -135,12 +141,12 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
}
fun cancelActiveSubscriptionIfNecessary(): Completable {
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
return Single.fromCallable { InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType) }.flatMapCompletable {
if (it) {
Log.d(TAG, "Cancelling active subscription...", true)
cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().updateLocalStateForManualCancellation()
cancelActiveSubscription(subscriberType).doOnComplete {
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
MultiDeviceSubscriptionSyncRequestJob.enqueue()
}
} else {
@@ -149,13 +155,37 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
}
fun setSubscriptionLevel(gatewayRequest: GatewayRequest, isLongRunning: Boolean): Completable {
val subscriptionLevel = gatewayRequest.level.toString()
val uiSessionKey = gatewayRequest.uiSessionKey
fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single<PaymentSourceType> {
return Single.fromCallable {
InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType()
}
}
fun setSubscriptionLevel(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable {
val subscriptionLevel = inAppPayment.data.level.toString()
val isLongRunning = paymentSourceType.isBankTransfer
val subscriberType = inAppPayment.type.requireSubscriberType()
val errorSource = subscriberType.inAppPaymentType.toErrorSource()
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
subscriberId = subscriber.subscriberId,
data = inAppPayment.data.copy(
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
)
)
val timeoutError = if (isLongRunning) {
BadgeRedemptionError.DonationPending(errorSource, inAppPayment)
} else {
BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource)
}
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
Single
@@ -165,13 +195,13 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize(),
SubscriptionReceiptRequestResponseJob.MUTEX
subscriberType
)
}
.flatMapCompletable {
if (it.status == 200 || it.status == 204) {
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true)
SignalStore.donationsValues().updateLocalStateForLocalSubscribe()
SignalStore.donationsValues().updateLocalStateForLocalSubscribe(subscriberType)
syncAccountRecord().subscribe()
LevelUpdate.updateProcessingState(false)
Completable.complete()
@@ -186,54 +216,24 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
LevelUpdate.updateProcessingState(false)
it.flattenResult().ignoreElement()
}
}.andThen {
Log.d(TAG, "Enqueuing request response job chain.", true)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, terminalDonation).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
val timeoutError: DonationError = if (isLongRunning) {
DonationError.donationPending(DonationErrorSource.MONTHLY, gatewayRequest)
} else {
DonationError.timeoutWaitingForToken(DonationErrorSource.MONTHLY)
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Subscription request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY))
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(timeoutError)
}
}.andThen(
Single.fromCallable {
Log.d(TAG, "Enqueuing request response job chain.", true)
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!!
InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue()
}.flatMap {
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter {
it.state == InAppPaymentTable.State.END
}.take(1).map {
if (it.data.error != null) {
Log.d(TAG, "Failure during redemption chain.", true)
throw DonationError.genericBadgeRedemptionFailure(errorSource)
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(timeoutError)
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(timeoutError)
}
}
it
}.firstOrError()
}.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement()
)
}.doOnError {
LevelUpdate.updateProcessingState(false)
}.subscribeOn(Schedulers.io())
@@ -244,6 +244,8 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
companion object {
private val TAG = Log.tag(RecurringInAppPaymentRepository::class.java)
fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation {
Log.d(tag, "Retrieving level update operation for $subscriptionLevel")
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
@@ -269,10 +271,10 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
* Update local state information and schedule a storage sync for the change. This method
* assumes you've already properly called the DELETE method for the stored ID on the server.
*/
private fun updateLocalSubscriptionStateAndScheduleDataSync(): Completable {
private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
return Completable.fromAction {
Log.d(TAG, "Marking subscription cancelled...", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
MultiDeviceSubscriptionSyncRequestJob.enqueue()
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()

View File

@@ -13,11 +13,13 @@ import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.signal.donations.json.StripeIntentStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.OneTimeDonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -43,11 +45,14 @@ import org.whispersystems.signalservice.internal.ServiceResponse
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the PaymentIntent via the Stripe API
*/
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
class StripeRepository(
activity: Activity,
private val subscriberType: InAppPaymentSubscriberRecord.Type = InAppPaymentSubscriberRecord.Type.DONATION
) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
private val recurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService())
fun isGooglePayAvailable(): Completable {
return googlePayApi.queryIsReadyToPay()
@@ -96,7 +101,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
.onErrorResumeNext {
OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
}
.flatMap { result ->
val recipient = Recipient.resolved(badgeRecipient)
@@ -104,9 +109,9 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(OneTimeDonationError.AmountTooSmallError(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(OneTimeDonationError.AmountTooLargeError(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(OneTimeDonationError.InvalidCurrencyError(errorSource))
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
}
}.subscribeOn(Schedulers.io())
@@ -165,7 +170,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
* situation by simply deleting the old subscriber id on the service and replacing it.
*/
private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
return Single.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
.flatMap {
Single.fromCallable {
ApplicationDependencies
@@ -175,7 +180,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
.flatMap { serviceResponse ->
if (retryOn409 && serviceResponse.status == 409) {
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
recurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
} else {
serviceResponse.flattenResult()
}
@@ -230,24 +235,24 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
): Completable {
return Single.fromCallable {
Log.d(TAG, "Getting the subscriber...")
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
InAppPaymentsRepository.requireSubscriber(subscriberType)
}.flatMapCompletable { subscriberRecord ->
Log.d(TAG, "Setting default payment method via Signal service...")
Single.fromCallable {
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
ApplicationDependencies
.getDonationsService()
.setDefaultIdealPaymentMethod(it.subscriberId, setupIntentId)
.setDefaultIdealPaymentMethod(subscriberRecord.subscriberId, setupIntentId)
} else {
ApplicationDependencies
.getDonationsService()
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
.setDefaultStripePaymentMethod(subscriberRecord.subscriberId, paymentMethodId)
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method via Signal service!")
Log.d(TAG, "Storing the subscription payment source type locally.")
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriberRecord.subscriberId, paymentSourceType.toPaymentMethodType())
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method via Signal service!")
Log.d(TAG, "Storing the subscription payment source type locally.")
SignalStore.donationsValues().setSubscriptionPaymentSourceType(paymentSourceType)
}
}

View File

@@ -9,13 +9,21 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
@@ -52,8 +60,30 @@ class TerminalDonationDelegate(
val verifiedMonthlyDonation: Stripe3DSData? = SignalStore.donationsValues().consumeVerifiedSubscription3DSData()
if (verifiedMonthlyDonation != null) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.gatewayRequest).build().toBundle()
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.inAppPayment).build().toBundle()
}.show(fragmentManager, null)
}
handleInAppPaymentSheets()
}
private fun handleInAppPaymentSheets() {
lifecycleDisposable += Single.fromCallable {
SignalDatabase.inAppPayments.consumeInAppPaymentsToNotifyUser()
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments ->
for (payment in inAppPayments) {
if (payment.data.error == null && payment.state == InAppPaymentTable.State.END) {
ThanksForYourSupportBottomSheetDialogFragment()
.apply { arguments = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(Badges.fromDatabaseBadge(payment.data.badge!!)).build().toBundle() }
.show(fragmentManager, null)
} else if (payment.data.error != null && payment.state == InAppPaymentTable.State.PENDING) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(payment).build().toBundle()
}.show(fragmentManager, null)
} else if (payment.data.error != null && payment.data.cancellation != null && payment.data.cancellation.reason != InAppPaymentData.Cancellation.Reason.MANUAL && SignalStore.donationsValues().showMonthlyDonationCanceledDialog) {
MonthlyDonationCanceledBottomSheetDialogFragment.show(fragmentManager)
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: SetCurrencyViewModel by viewModels(
factoryProducer = {
val args = SetCurrencyFragmentArgs.fromBundle(requireArguments())
SetCurrencyViewModel.Factory(args.isBoost, args.supportedCurrencyCodes.toList())
SetCurrencyViewModel.Factory(args.inAppPaymentType, args.supportedCurrencyCodes.toList())
}
)

View File

@@ -5,24 +5,27 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel(
private val isOneTime: Boolean,
private val inAppPaymentType: InAppPaymentTable.Type,
supportedCurrencyCodes: List<String>
) : ViewModel() {
private val store = Store(
SetCurrencyState(
selectedCurrencyCode = if (isOneTime) {
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
selectedCurrencyCode = if (inAppPaymentType.recurring) {
SignalStore.donationsValues().getSubscriptionCurrency(inAppPaymentType.requireSubscriberType()).currencyCode
} else {
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
},
currencies = supportedCurrencyCodes
.map(Currency::getInstance)
@@ -35,19 +38,22 @@ class SetCurrencyViewModel(
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
if (isOneTime) {
if (!inAppPaymentType.recurring) {
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
} else {
val currency = Currency.getInstance(selectedCurrencyCode)
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
val subscriber = InAppPaymentsRepository.getSubscriber(currency, inAppPaymentType.requireSubscriberType())
if (subscriber != null) {
SignalStore.donationsValues().setSubscriber(subscriber)
InAppPaymentsRepository.setSubscriber(subscriber)
} else {
SignalStore.donationsValues().setSubscriber(
Subscriber(
InAppPaymentsRepository.setSubscriber(
InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currencyCode = currency.currencyCode
currencyCode = currency.currencyCode,
type = inAppPaymentType.requireSubscriberType(),
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN
)
)
}
@@ -83,9 +89,9 @@ class SetCurrencyViewModel(
}
}
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
class Factory(private val inAppPaymentType: InAppPaymentTable.Type, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
return modelClass.cast(SetCurrencyViewModel(inAppPaymentType, supportedCurrencyCodes))!!
}
}
}

View File

@@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.InAppPaymentTable
sealed class DonateToSignalAction {
data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest, val isLongRunning: Boolean) : DonateToSignalAction()
data class DisplayCurrencySelectionDialog(val inAppPaymentType: InAppPaymentTable.Type, val supportedCurrencies: List<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val inAppPayment: InAppPaymentTable.InAppPayment) : DonateToSignalAction()
object CancelSubscription : DonateToSignalAction()
data class UpdateSubscription(val inAppPayment: InAppPaymentTable.InAppPayment, val isLongRunning: Boolean) : DonateToSignalAction()
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
/**
* Activity wrapper for donate to signal screen. An activity is needed because Google Pay uses the
@@ -20,7 +21,7 @@ class DonateToSignalActivity : FragmentWrapperActivity(), DonationPaymentCompone
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
override fun getFragment(): Fragment {
return NavHostFragment.create(R.navigation.donate_to_signal, DonateToSignalFragmentArgs.Builder(DonateToSignalType.ONE_TIME).build().toBundle())
return NavHostFragment.create(R.navigation.donate_to_signal, DonateToSignalFragmentArgs.Builder(InAppPaymentTable.Type.ONE_TIME_DONATION).build().toBundle())
}
@Suppress("DEPRECATION")

View File

@@ -19,6 +19,7 @@ import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -27,12 +28,10 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
@@ -68,9 +67,9 @@ class DonateToSignalFragment :
companion object {
@JvmStatic
fun create(donateToSignalType: DonateToSignalType): DialogFragment {
fun create(inAppPaymentType: InAppPaymentTable.Type): DialogFragment {
return Dialog().apply {
arguments = DonateToSignalFragmentArgs.Builder(donateToSignalType).build().toBundle()
arguments = DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
}
}
}
@@ -108,7 +107,11 @@ class DonateToSignalFragment :
}
override fun bindAdapter(adapter: MappingAdapter) {
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.ONE_TIME, DonationErrorSource.MONTHLY)
donationCheckoutDelegate = DonationCheckoutDelegate(
this,
this,
viewModel.inAppPaymentId
)
val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -137,7 +140,7 @@ class DonateToSignalFragment :
when (action) {
is DonateToSignalAction.DisplayCurrencySelectionDialog -> {
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToSetDonationCurrencyFragment(
action.donateToSignalType == DonateToSignalType.ONE_TIME,
action.inAppPaymentType,
action.supportedCurrencies.toTypedArray()
)
@@ -145,8 +148,8 @@ class DonateToSignalFragment :
}
is DonateToSignalAction.DisplayGatewaySelectorDialog -> {
Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}")
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest)
Log.d(TAG, "Presenting gateway selector for ${action.inAppPayment}")
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.inAppPayment)
findNavController().safeNavigate(navAction)
}
@@ -155,7 +158,8 @@ class DonateToSignalFragment :
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.CANCEL_SUBSCRIPTION,
action.gatewayRequest
null,
InAppPaymentTable.Type.RECURRING_DONATION
)
)
}
@@ -164,7 +168,8 @@ class DonateToSignalFragment :
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.UPDATE_SUBSCRIPTION,
action.gatewayRequest
action.inAppPayment,
action.inAppPayment.type
)
)
}
@@ -234,7 +239,7 @@ class DonateToSignalFragment :
customPref(
DonationPillToggle.Model(
selected = state.donateToSignalType,
selected = state.inAppPaymentType,
onClick = {
viewModel.toggleDonationType()
}
@@ -243,15 +248,15 @@ class DonateToSignalFragment :
space(10.dp)
when (state.donateToSignalType) {
DonateToSignalType.ONE_TIME -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState)
DonateToSignalType.MONTHLY -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState)
DonateToSignalType.GIFT -> error("This fragment does not support gifts.")
when (state.inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState)
InAppPaymentTable.Type.RECURRING_DONATION -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState)
else -> error("This fragment does not support ${state.inAppPaymentType}.")
}
space(20.dp)
if (state.donateToSignalType == DonateToSignalType.MONTHLY && state.monthlyDonationState.isSubscriptionActive) {
if (state.inAppPaymentType == InAppPaymentTable.Type.RECURRING_DONATION && state.monthlyDonationState.isSubscriptionActive) {
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
isEnabled = state.canUpdate,
@@ -317,7 +322,7 @@ class DonateToSignalFragment :
}
private fun showDonationPendingDialog(state: DonateToSignalState) {
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
val message = if (state.inAppPaymentType == InAppPaymentTable.Type.ONE_TIME_DONATION) {
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
@@ -437,33 +442,34 @@ class DonateToSignalFragment :
}
}
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, inAppPayment, inAppPayment.type))
}
override fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) {
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
gatewayRequest
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment))
}
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(gatewayRequest))
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment))
}
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse))
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(Badges.fromDatabaseBadge(inAppPayment.data.badge!!)))
}
override fun onProcessorActionProcessed() {
@@ -481,7 +487,7 @@ class DonateToSignalFragment :
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(inAppPayment))
}
}

View File

@@ -5,6 +5,8 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isLongRunning
import org.thoughtcrime.securesms.database.model.isPending
@@ -16,72 +18,72 @@ import java.util.Currency
import java.util.concurrent.TimeUnit
data class DonateToSignalState(
val donateToSignalType: DonateToSignalType,
val inAppPaymentType: InAppPaymentTable.Type,
val oneTimeDonationState: OneTimeDonationState = OneTimeDonationState(),
val monthlyDonationState: MonthlyDonationState = MonthlyDonationState()
) {
val areFieldsEnabled: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.donationStage == DonationStage.READY
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.donationStage == DonationStage.READY
else -> error("This flow does not support $inAppPaymentType")
}
val badge: Badge?
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.badge
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedSubscription?.badge
else -> error("This flow does not support $inAppPaymentType")
}
val canSetCurrency: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
else -> error("This flow does not support $inAppPaymentType")
}
val selectedCurrency: Currency
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.selectedCurrency
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedCurrency
else -> error("This flow does not support $inAppPaymentType")
}
val selectableCurrencyCodes: List<String>
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes
DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.selectableCurrencyCodes
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectableCurrencyCodes
else -> error("This flow does not support $inAppPaymentType")
}
val level: Int
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> 1
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> 1
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedSubscription!!.level
else -> error("This flow does not support $inAppPaymentType")
}
val continueEnabled: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
else -> error("This flow does not support $inAppPaymentType")
}
val canContinue: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
DonateToSignalType.MONTHLY -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
InAppPaymentTable.Type.RECURRING_DONATION -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
else -> error("This flow does not support $inAppPaymentType")
}
val canUpdate: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
DonateToSignalType.GIFT -> error("This flow does not support gifts")
get() = when (inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> false
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && monthlyDonationState.isSelectionValid
else -> error("This flow does not support $inAppPaymentType")
}
val isUpdateLongRunning: Boolean
@@ -112,7 +114,7 @@ data class DonateToSignalState(
}
data class MonthlyDonationState(
val selectedCurrency: Currency = SignalStore.donationsValues().getSubscriptionCurrency(),
val selectedCurrency: Currency = SignalStore.donationsValues().getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION),
val subscriptions: List<Subscription> = emptyList(),
private val _activeSubscription: ActiveSubscription? = null,
val selectedSubscription: Subscription? = null,

View File

@@ -1,20 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
@Parcelize
enum class DonateToSignalType(val requestCode: Short) : Parcelable {
ONE_TIME(16141),
MONTHLY(16142),
GIFT(16143);
fun toErrorSource(): DonationErrorSource {
return when (this) {
ONE_TIME -> DonationErrorSource.ONE_TIME
MONTHLY -> DonationErrorSource.MONTHLY
GIFT -> DonationErrorSource.GIFT
}
}
}

View File

@@ -3,30 +3,36 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.money.PlatformCurrencyUtil
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isExpired
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.rx.RxStore
@@ -44,28 +50,30 @@ import java.util.Optional
* only in charge of rendering our "current view of the world."
*/
class DonateToSignalViewModel(
startType: DonateToSignalType,
private val subscriptionsRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
startType: InAppPaymentTable.Type,
private val subscriptionsRepository: RecurringInAppPaymentRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(DonateToSignalViewModel::class.java)
}
private val store = RxStore(DonateToSignalState(donateToSignalType = startType))
private val store = RxStore(DonateToSignalState(inAppPaymentType = startType))
private val oneTimeDonationDisposables = CompositeDisposable()
private val monthlyDonationDisposables = CompositeDisposable()
private val networkDisposable = CompositeDisposable()
private val actionDisposable = CompositeDisposable()
private val _actions = PublishSubject.create<DonateToSignalAction>()
private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
private val _inAppPaymentId = BehaviorProcessor.create<InAppPaymentTable.InAppPaymentId>()
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
val uiSessionKey: Long = System.currentTimeMillis()
val inAppPaymentId: Flowable<InAppPaymentTable.InAppPaymentId> = _inAppPaymentId.onBackpressureLatest().distinctUntilChanged()
init {
initializeOneTimeDonationState(oneTimeDonationRepository)
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
initializeMonthlyDonationState(subscriptionsRepository)
networkDisposable += InternetConnectionObserver
@@ -89,45 +97,49 @@ class DonateToSignalViewModel(
fun retryOneTimeDonationState() {
if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) {
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) }
initializeOneTimeDonationState(oneTimeDonationRepository)
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
}
}
fun requestChangeCurrency() {
val snapshot = store.state
if (snapshot.canSetCurrency) {
_actions.onNext(DonateToSignalAction.DisplayCurrencySelectionDialog(snapshot.donateToSignalType, snapshot.selectableCurrencyCodes))
_actions.onNext(DonateToSignalAction.DisplayCurrencySelectionDialog(snapshot.inAppPaymentType, snapshot.selectableCurrencyCodes))
}
}
fun requestSelectGateway() {
val snapshot = store.state
if (snapshot.areFieldsEnabled) {
_actions.onNext(DonateToSignalAction.DisplayGatewaySelectorDialog(createGatewayRequest(snapshot)))
actionDisposable += createInAppPayment(snapshot).subscribeBy {
_actions.onNext(DonateToSignalAction.DisplayGatewaySelectorDialog(it))
}
}
}
fun updateSubscription() {
val snapshot = store.state
if (snapshot.areFieldsEnabled) {
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot), snapshot.isUpdateLongRunning))
actionDisposable += createInAppPayment(snapshot).subscribeBy {
_actions.onNext(DonateToSignalAction.UpdateSubscription(it, snapshot.isUpdateLongRunning))
}
}
}
fun cancelSubscription() {
val snapshot = store.state
if (snapshot.areFieldsEnabled) {
_actions.onNext(DonateToSignalAction.CancelSubscription(createGatewayRequest(snapshot)))
_actions.onNext(DonateToSignalAction.CancelSubscription)
}
}
fun toggleDonationType() {
store.update {
it.copy(
donateToSignalType = when (it.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonateToSignalType.MONTHLY
DonateToSignalType.MONTHLY -> DonateToSignalType.ONE_TIME
DonateToSignalType.GIFT -> error("We are in an illegal state")
inAppPaymentType = when (it.inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> InAppPaymentTable.Type.RECURRING_DONATION
InAppPaymentTable.Type.RECURRING_DONATION -> InAppPaymentTable.Type.ONE_TIME_DONATION
else -> error("Should never get here.")
}
)
}
@@ -169,7 +181,7 @@ class DonateToSignalViewModel(
fun refreshActiveSubscription() {
subscriptionsRepository
.getActiveSubscription()
.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
.subscribeBy(
onSuccess = {
_activeSubscription.onNext(it)
@@ -180,25 +192,39 @@ class DonateToSignalViewModel(
)
}
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
private fun createInAppPayment(snapshot: DonateToSignalState): Single<InAppPaymentTable.InAppPayment> {
val amount = getAmount(snapshot)
return GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = snapshot.donateToSignalType,
badge = snapshot.badge!!,
label = snapshot.badge!!.description,
price = amount.amount,
currencyCode = amount.currency.currencyCode,
level = snapshot.level.toLong(),
recipientId = Recipient.self().id
)
return Single.fromCallable {
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
type = snapshot.inAppPaymentType,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
badge = snapshot.badge?.let { Badges.toDatabaseBadge(it) },
label = snapshot.badge?.description ?: "",
amount = amount.toFiatValue(),
level = snapshot.level.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
)
_inAppPaymentId.onNext(id)
SignalDatabase.inAppPayments.getById(id)!!
}
}
private fun getAmount(snapshot: DonateToSignalState): FiatMoney {
return when (snapshot.donateToSignalType) {
DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState)
DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost()
DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.")
return when (snapshot.inAppPaymentType) {
InAppPaymentTable.Type.ONE_TIME_DONATION -> getOneTimeAmount(snapshot.oneTimeDonationState)
InAppPaymentTable.Type.RECURRING_DONATION -> getSelectedSubscriptionCost()
else -> error("This ViewModel does not support ${snapshot.inAppPaymentType}.")
}
}
@@ -210,8 +236,8 @@ class DonateToSignalViewModel(
}
}
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
private fun initializeOneTimeDonationState(oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository) {
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.ONE_TIME_DONATION).map {
when (it) {
is DonationRedemptionJobStatus.PendingExternalVerification -> Optional.ofNullable(it.pendingOneTimeDonation)
@@ -238,7 +264,7 @@ class DonateToSignalViewModel(
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
}
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getBoostBadge().subscribeBy(
onSuccess = { badge ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
},
@@ -247,7 +273,7 @@ class DonateToSignalViewModel(
}
)
oneTimeDonationDisposables += oneTimeDonationRepository.getMinimumDonationAmounts().subscribeBy(
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getMinimumDonationAmounts().subscribeBy(
onSuccess = { amountMap ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(minimumDonationAmounts = amountMap)) }
},
@@ -256,7 +282,7 @@ class DonateToSignalViewModel(
}
)
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeDonationRepository.getBoosts().toObservable()
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeInAppPaymentRepository.getBoosts().toObservable()
val oneTimeCurrency: Observable<Currency> = SignalStore.donationsValues().observableOneTimeCurrency
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
@@ -294,7 +320,7 @@ class DonateToSignalViewModel(
)
}
private fun initializeMonthlyDonationState(subscriptionsRepository: MonthlyDonationRepository) {
private fun initializeMonthlyDonationState(subscriptionsRepository: RecurringInAppPaymentRepository) {
monitorLevelUpdateProcessing()
val allSubscriptions = subscriptionsRepository.getSubscriptions()
@@ -305,7 +331,7 @@ class DonateToSignalViewModel(
}
private fun monitorLevelUpdateProcessing() {
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = DonationRedemptionJobWatcher.watchSubscriptionRedemption()
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.RECURRING_DONATION)
monthlyDonationDisposables += Observable
.combineLatest(redemptionJobStatus, LevelUpdate.isProcessing, ::Pair)
@@ -361,13 +387,13 @@ class DonateToSignalViewModel(
onSuccess = { subscriptions ->
if (subscriptions.isNotEmpty()) {
val priceCurrencies = subscriptions[0].prices.map { it.currency }
val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)
if (selectedCurrency !in priceCurrencies) {
Log.w(TAG, "Unsupported currency selection. Defaulting to USD. $selectedCurrency isn't supported.")
val usd = PlatformCurrencyUtil.USD
val newSubscriber = SignalStore.donationsValues().getSubscriber(usd) ?: Subscriber(SubscriberId.generate(), usd.currencyCode)
SignalStore.donationsValues().setSubscriber(newSubscriber)
val newSubscriber = InAppPaymentsRepository.getSubscriber(usd, InAppPaymentSubscriberRecord.Type.DONATION) ?: InAppPaymentSubscriberRecord(SubscriberId.generate(), usd.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN)
InAppPaymentsRepository.setSubscriber(newSubscriber)
subscriptionsRepository.syncAccountRecord().subscribe()
}
}
@@ -377,7 +403,7 @@ class DonateToSignalViewModel(
}
private fun monitorSubscriptionCurrency() {
monthlyDonationDisposables += SignalStore.donationsValues().observableSubscriptionCurrency.subscribe {
monthlyDonationDisposables += SignalStore.donationsValues().observableRecurringDonationCurrency.subscribe {
store.update { state ->
state.copy(monthlyDonationState = state.monthlyDonationState.copy(selectedCurrency = it))
}
@@ -389,16 +415,17 @@ class DonateToSignalViewModel(
oneTimeDonationDisposables.clear()
monthlyDonationDisposables.clear()
networkDisposable.clear()
actionDisposable.clear()
store.dispose()
}
class Factory(
private val startType: DonateToSignalType,
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
private val startType: InAppPaymentTable.Type,
private val subscriptionsRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeDonationRepository)) as T
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeInAppPaymentRepository)) as T
}
}
}

View File

@@ -13,8 +13,10 @@ import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
@@ -22,10 +24,11 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
@@ -34,12 +37,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.tr
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.DonationErrorParams
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.math.BigDecimal
import java.util.Currency
/**
* Abstracts out some common UI-level interactions between gift flow and normal donate flow.
@@ -47,9 +50,7 @@ import java.util.Currency
class DonationCheckoutDelegate(
private val fragment: Fragment,
private val callback: Callback,
private val uiSessionKey: Long,
errorSource: DonationErrorSource,
vararg additionalSources: DonationErrorSource
inAppPaymentIdSource: Flowable<InAppPaymentTable.InAppPaymentId>
) : DefaultLifecycleObserver {
companion object {
@@ -70,7 +71,7 @@ class DonationCheckoutDelegate(
init {
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
ErrorHandler().attach(fragment, callback, inAppPaymentIdSource)
}
override fun onCreate(owner: LifecycleOwner) {
@@ -82,8 +83,8 @@ class DonationCheckoutDelegate(
if (bundle.containsKey(GatewaySelectorBottomSheet.FAILURE_KEY)) {
callback.showSepaEuroMaximumDialog(FiatMoney(bundle.getSerializable(GatewaySelectorBottomSheet.SEPA_EURO_MAX) as BigDecimal, CurrencyUtil.EURO))
} else {
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
handleGatewaySelectionResponse(response)
val inAppPayment: InAppPaymentTable.InAppPayment = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, InAppPaymentTable.InAppPayment::class.java)!!
handleGatewaySelectionResponse(inAppPayment)
}
}
@@ -103,8 +104,8 @@ class DonationCheckoutDelegate(
}
fragment.setFragmentResultListener(BankTransferRequestKeys.PENDING_KEY) { _, bundle ->
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, GatewayRequest::class.java)!!
callback.navigateToDonationPending(gatewayRequest = request)
val request: InAppPaymentTable.InAppPayment = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, InAppPaymentTable.InAppPayment::class.java)!!
callback.navigateToDonationPending(inAppPayment = request)
}
fragment.setFragmentResultListener(PayPalPaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
@@ -113,17 +114,18 @@ class DonationCheckoutDelegate(
}
}
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
if (InAppDonations.isPaymentSourceAvailable(gatewayResponse.gateway.toPaymentSourceType(), gatewayResponse.request.donateToSignalType)) {
when (gatewayResponse.gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchBankTransfer(gatewayResponse)
GatewayResponse.Gateway.IDEAL -> launchBankTransfer(gatewayResponse)
private fun handleGatewaySelectionResponse(inAppPayment: InAppPaymentTable.InAppPayment) {
if (InAppDonations.isPaymentSourceAvailable(inAppPayment.data.paymentMethodType.toPaymentSourceType(), inAppPayment.type)) {
when (inAppPayment.data.paymentMethodType) {
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> launchGooglePay(inAppPayment)
InAppPaymentData.PaymentMethodType.PAYPAL -> launchPayPal(inAppPayment)
InAppPaymentData.PaymentMethodType.CARD -> launchCreditCard(inAppPayment)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> launchBankTransfer(inAppPayment)
InAppPaymentData.PaymentMethodType.IDEAL -> launchBankTransfer(inAppPayment)
else -> error("Unsupported payment method type")
}
} else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
error("Unsupported combination! ${inAppPayment.data.paymentMethodType} ${inAppPayment.type}")
}
}
@@ -140,8 +142,7 @@ class DonationCheckoutDelegate(
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
SignalStore.donationsValues().removeTerminalDonation(result.request.level)
callback.onPaymentComplete(result.request)
callback.onPaymentComplete(result.inAppPayment!!)
}
}
@@ -159,28 +160,28 @@ class DonationCheckoutDelegate(
}
}
private fun launchPayPal(gatewayResponse: GatewayResponse) {
callback.navigateToPayPalPaymentInProgress(gatewayResponse.request)
private fun launchPayPal(inAppPayment: InAppPaymentTable.InAppPayment) {
callback.navigateToPayPalPaymentInProgress(inAppPayment)
}
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
private fun launchGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) {
viewModel.provideGatewayRequestForGooglePay(inAppPayment)
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
label = gatewayResponse.request.label,
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
price = inAppPayment.data.amount!!.toFiatMoney(),
label = inAppPayment.data.label,
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPayment.type)
)
}
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
callback.navigateToCreditCardForm(gatewayResponse.request)
private fun launchCreditCard(inAppPayment: InAppPaymentTable.InAppPayment) {
callback.navigateToCreditCardForm(inAppPayment)
}
private fun launchBankTransfer(gatewayResponse: GatewayResponse) {
if (gatewayResponse.request.donateToSignalType != DonateToSignalType.MONTHLY && gatewayResponse.gateway == GatewayResponse.Gateway.IDEAL) {
callback.navigateToIdealDetailsFragment(gatewayResponse.request)
private fun launchBankTransfer(inAppPayment: InAppPaymentTable.InAppPayment) {
if (!inAppPayment.type.recurring && inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
callback.navigateToIdealDetailsFragment(inAppPayment)
} else {
callback.navigateToBankTransferMandate(gatewayResponse)
callback.navigateToBankTransferMandate(inAppPayment)
}
}
@@ -200,26 +201,26 @@ class DonationCheckoutDelegate(
)
}
inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback {
inner class GooglePayRequestCallback(private val inAppPayment: InAppPaymentTable.InAppPayment) : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
stripePaymentViewModel.providePaymentData(paymentData)
callback.navigateToStripePaymentInProgress(request)
callback.navigateToStripePaymentInProgress(inAppPayment)
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true)
val error = DonationError.getGooglePayRequestTokenError(
source = when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
},
throwable = googlePayException
)
DonationError.routeDonationError(fragment.requireContext(), error)
InAppPaymentsRepository.updateInAppPayment(
inAppPayment.copy(
notified = false,
data = inAppPayment.data.copy(
error = InAppPaymentData.Error(
type = InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN
)
)
)
).subscribe()
}
override fun onCancelled() {
@@ -236,7 +237,19 @@ class DonationCheckoutDelegate(
private var errorDialog: DialogInterface? = null
private var errorHandlerCallback: ErrorHandlerCallback? = null
fun attach(fragment: Fragment, errorHandlerCallback: ErrorHandlerCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
fun attach(
fragment: Fragment,
errorHandlerCallback: ErrorHandlerCallback?,
inAppPaymentId: InAppPaymentTable.InAppPaymentId
) {
attach(fragment, errorHandlerCallback, Flowable.just(inAppPaymentId))
}
fun attach(
fragment: Fragment,
errorHandlerCallback: ErrorHandlerCallback?,
inAppPaymentIdSource: Flowable<InAppPaymentTable.InAppPaymentId>
) {
this.fragment = fragment
this.errorHandlerCallback = errorHandlerCallback
@@ -244,12 +257,26 @@ class DonationCheckoutDelegate(
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
disposables.bindTo(fragment.viewLifecycleOwner)
disposables += registerErrorSource(errorSource)
additionalSources.forEach { source ->
disposables += registerErrorSource(source)
}
disposables += inAppPaymentIdSource
.switchMap { filterUnnotifiedErrors(it) }
.doOnNext {
SignalDatabase.inAppPayments.update(it.copy(notified = true))
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
showErrorDialog(it)
}
disposables += registerUiSession(uiSessionKey)
disposables += inAppPaymentIdSource
.switchMap { InAppPaymentsRepository.observeTemporaryErrors(it) }
.onBackpressureLatest()
.concatMapSingle { (id, err) -> Single.fromCallable { SignalDatabase.inAppPayments.getById(id)!! to err } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { (inAppPayment, error) ->
handleTemporaryError(inAppPayment, error)
}
}
override fun onDestroy(owner: LifecycleOwner) {
@@ -258,95 +285,105 @@ class DonationCheckoutDelegate(
errorHandlerCallback = null
}
private fun registerErrorSource(errorSource: DonationErrorSource): Disposable {
return DonationError.getErrorsForSource(errorSource)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { error ->
showErrorDialog(error)
private fun filterUnnotifiedErrors(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<InAppPaymentTable.InAppPayment> {
return InAppPaymentsRepository.observeUpdates(inAppPaymentId)
.subscribeOn(Schedulers.io())
.filter {
!it.notified && it.data.error != null
}
}
private fun registerUiSession(uiSessionKey: Long): Disposable {
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
showErrorDialog(it)
private fun handleTemporaryError(inAppPayment: InAppPaymentTable.InAppPayment, throwable: Throwable) {
when (throwable) {
is DonationError.UserCancelledPaymentError -> {
Log.d(TAG, "User cancelled out of payment flow.", true)
}
is DonationError.BadgeRedemptionError.DonationPending -> {
Log.d(TAG, "User launched an external application.", true)
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
}
is DonationError.UserLaunchedExternalApplication -> {
Log.d(TAG, "Long-running donation is still pending.", true)
errorHandlerCallback?.navigateToDonationPending(inAppPayment)
}
else -> {
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(),
throwable,
DialogHandler()
)
}
}
}
private fun showErrorDialog(throwable: Throwable) {
private fun showErrorDialog(inAppPayment: InAppPaymentTable.InAppPayment) {
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
Log.d(TAG, "Already displaying an error dialog. Skipping. ${inAppPayment.data.error}", true)
return
}
if (throwable is DonationError.UserCancelledPaymentError) {
Log.d(TAG, "User cancelled out of payment flow.", true)
val error = inAppPayment.data.error
if (error == null) {
Log.d(TAG, "InAppPayment does not contain an error. Skipping.", true)
return
}
if (throwable is DonationError.UserLaunchedExternalApplication) {
Log.d(TAG, "User launched an external application.", true)
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
return
}
if (throwable is DonationError.BadgeRedemptionError.DonationPending) {
Log.d(TAG, "Long-running donation is still pending.", true)
errorHandlerCallback?.navigateToDonationPending(throwable.gatewayRequest)
return
}
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
var tryAgain = false
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onDialogDismissed() {
errorDialog = null
if (!tryAgain) {
tryAgain = false
fragment?.findNavController()?.popBackStack()
}
}
when (error.type) {
else -> {
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(),
inAppPayment,
DialogHandler()
)
}
)
}
}
private inner class DialogHandler : DonationErrorDialogs.DialogCallback() {
var tryAgain = false
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onDialogDismissed() {
errorDialog = null
if (!tryAgain) {
tryAgain = false
fragment?.findNavController()?.popBackStack()
}
}
}
}
interface ErrorHandlerCallback {
fun onUserLaunchedAnExternalApplication()
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment)
}
interface Callback : ErrorHandlerCallback {
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment)
fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment)
fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment)
fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment)
fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment)
fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment)
fun onProcessorActionProcessed()
fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney)
}

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import androidx.lifecycle.ViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.whispersystems.signalservice.api.util.Preconditions
/**
@@ -14,17 +14,17 @@ class DonationCheckoutViewModel : ViewModel() {
private val TAG = Log.tag(DonationCheckoutViewModel::class.java)
}
private var gatewayRequest: GatewayRequest? = null
private var inAppPayment: InAppPaymentTable.InAppPayment? = null
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
fun provideGatewayRequestForGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) {
Log.d(TAG, "Provided with a gateway request.")
Preconditions.checkState(gatewayRequest == null)
gatewayRequest = request
Preconditions.checkState(this.inAppPayment == null)
this.inAppPayment = inAppPayment
}
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
val request = gatewayRequest
gatewayRequest = null
fun consumeGatewayRequestForGooglePay(): InAppPaymentTable.InAppPayment? {
val request = inAppPayment
inAppPayment = null
return request
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.databinding.DonationPillToggleBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
@@ -15,7 +16,7 @@ object DonationPillToggle {
}
class Model(
val selected: DonateToSignalType,
val selected: InAppPaymentTable.Type,
val onClick: () -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
@@ -28,13 +29,13 @@ object DonationPillToggle {
private class ViewHolder(binding: DonationPillToggleBinding) : BindingViewHolder<Model, DonationPillToggleBinding>(binding) {
override fun bind(model: Model) {
when (model.selected) {
DonateToSignalType.ONE_TIME -> {
InAppPaymentTable.Type.ONE_TIME_DONATION -> {
presentButtons(model, binding.oneTime, binding.monthly)
}
DonateToSignalType.MONTHLY -> {
InAppPaymentTable.Type.RECURRING_DONATION -> {
presentButtons(model, binding.monthly, binding.oneTime)
}
DonateToSignalType.GIFT -> {
else -> {
error("Unsupported donation type.")
}
}

View File

@@ -2,12 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.InAppPaymentTable
@Parcelize
class DonationProcessorActionResult(
val action: DonationProcessorAction,
val request: GatewayRequest,
val inAppPayment: InAppPaymentTable.InAppPayment?,
val status: Status
) : Parcelable {
enum class Status {

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
/**
* Wraps an InAppPaymentData.Error in a throwable.
*/
class InAppPaymentError(
val inAppPaymentDataError: InAppPaymentData.Error
) : Exception() {
companion object {
fun fromDonationError(donationError: DonationError): InAppPaymentError? {
val inAppPaymentDataError: InAppPaymentData.Error? = when (donationError) {
is DonationError.BadgeRedemptionError.DonationPending -> null
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION)
is DonationError.BadgeRedemptionError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.REDEMPTION)
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> null
is DonationError.PaymentProcessingError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING)
DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.INVALID_GIFT_RECIPIENT)
is DonationError.GooglePayError.RequestTokenError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN)
is DonationError.OneTimeDonationError.AmountTooLargeError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE)
is DonationError.OneTimeDonationError.AmountTooSmallError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL)
is DonationError.OneTimeDonationError.InvalidCurrencyError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.INVALID_CURRENCY)
is DonationError.PaymentSetupError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_SETUP)
is DonationError.PaymentSetupError.PayPalCodedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR, data_ = donationError.errorCode.toString())
is DonationError.PaymentSetupError.PayPalDeclinedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR, data_ = donationError.code.code.toString())
is DonationError.PaymentSetupError.StripeCodedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_CODED_ERROR, data_ = donationError.errorCode)
is DonationError.PaymentSetupError.StripeDeclinedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR, data_ = donationError.declineCode.rawCode)
is DonationError.PaymentSetupError.StripeFailureCodeError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_FAILURE, data_ = donationError.failureCode.rawCode)
is DonationError.UserCancelledPaymentError -> null
is DonationError.UserLaunchedExternalApplication -> null
}
return inAppPaymentDataError?.let { InAppPaymentError(it) }
}
}
}

View File

@@ -20,13 +20,13 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.ViewUtil
@@ -48,14 +48,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.inAppPayment.id)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
@@ -65,13 +58,14 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
binding.continueButton.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
// TODO [message-backups] Copy for this button in backups checkout flow.
binding.continueButton.text = if (args.inAppPayment.type == InAppPaymentTable.Type.RECURRING_DONATION) {
getString(
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.request.fiat))
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney()))
}
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
@@ -122,7 +116,8 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
findNavController().safeNavigate(
CreditCardFragmentDirections.actionCreditCardFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
args.inAppPayment,
args.inAppPayment.type
)
)
}

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.InAppPaymentTable
/**
* Encapsulates data returned from the credit card form that can be used
@@ -11,6 +11,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
*/
@Parcelize
data class CreditCardResult(
val gatewayRequest: GatewayRequest,
val inAppPayment: InAppPaymentTable.InAppPayment,
val creditCardData: StripeApi.CardData
) : Parcelable

View File

@@ -8,39 +8,40 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.recipients.Recipient
sealed interface GatewayOrderStrategy {
val orderedGateways: Set<GatewayResponse.Gateway>
val orderedGateways: Set<InAppPaymentData.PaymentMethodType>
private object Default : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
InAppPaymentData.PaymentMethodType.CARD,
InAppPaymentData.PaymentMethodType.PAYPAL,
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
InAppPaymentData.PaymentMethodType.IDEAL
)
}
private object NorthAmerica : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
InAppPaymentData.PaymentMethodType.PAYPAL,
InAppPaymentData.PaymentMethodType.CARD,
InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
InAppPaymentData.PaymentMethodType.IDEAL
)
}
private object Netherlands : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.IDEAL,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.SEPA_DEBIT
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
InAppPaymentData.PaymentMethodType.IDEAL,
InAppPaymentData.PaymentMethodType.PAYPAL,
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
InAppPaymentData.PaymentMethodType.CARD,
InAppPaymentData.PaymentMethodType.SEPA_DEBIT
)
}

View File

@@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.recipients.RecipientId
import java.math.BigDecimal
import java.util.Currency
@Parcelize
data class GatewayRequest(
val uiSessionKey: Long,
val donateToSignalType: DonateToSignalType,
val badge: Badge,
val label: String,
val price: BigDecimal,
val currencyCode: String,
val level: Long,
val recipientId: RecipientId,
val additionalMessage: String? = null
) : Parcelable {
@IgnoredOnParcel
val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode))
}

View File

@@ -1,26 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.donations.PaymentSourceType
@Parcelize
data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) : Parcelable {
enum class Gateway {
GOOGLE_PAY,
PAYPAL,
CREDIT_CARD,
SEPA_DEBIT,
IDEAL;
fun toPaymentSourceType(): PaymentSourceType {
return when (this) {
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
IDEAL -> PaymentSourceType.Stripe.IDEAL
}
}
}
}

View File

@@ -7,9 +7,11 @@ import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -18,11 +20,13 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -55,16 +59,17 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
return configure {
// TODO [message-backups] -- No badge on message backups.
customPref(
BadgeDisplay112.Model(
badge = state.badge,
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
withDisplayText = false
)
)
space(12.dp)
presentTitleAndSubtitle(requireContext(), args.request)
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
space(16.dp)
@@ -75,13 +80,14 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
return@configure
}
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state)
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
}
}
@@ -97,9 +103,11 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
GooglePayButton.Model(
isEnabled = true,
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
@@ -113,9 +121,11 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
customPref(
PayPalButton.Model(
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
},
isEnabled = true
)
@@ -130,10 +140,13 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
disableOnClick = true,
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
}
@@ -146,18 +159,21 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
disableOnClick = true,
onClick = {
val price = args.inAppPayment.data.amount!!.toFiatMoney()
if (state.sepaEuroMaximum != null &&
args.request.fiat.currency == CurrencyUtil.EURO &&
args.request.fiat.amount > state.sepaEuroMaximum.amount
price.currency == CurrencyUtil.EURO &&
price.amount > state.sepaEuroMaximum.amount
) {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
} else {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
}
)
@@ -171,10 +187,13 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
disableOnClick = true,
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.IDEAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
}
@@ -185,18 +204,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
const val FAILURE_KEY = "gateway_failure"
const val SEPA_EURO_MAX = "sepa_euro_max"
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, request: GatewayRequest) {
when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> presentMonthlyText(context, request)
DonateToSignalType.ONE_TIME -> presentOneTimeText(context, request)
DonateToSignalType.GIFT -> presentGiftText(context, request)
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
when (inAppPayment.type) {
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentTable.Type.RECURRING_BACKUP -> error("This type is not supported") // TODO [message-backups] necessary?
InAppPaymentTable.Type.RECURRING_DONATION -> presentMonthlyText(context, inAppPayment)
InAppPaymentTable.Type.ONE_TIME_DONATION -> presentOneTimeText(context, inAppPayment)
InAppPaymentTable.Type.ONE_TIME_GIFT -> presentGiftText(context, inAppPayment)
}
}
private fun DSLConfiguration.presentMonthlyText(context: Context, request: GatewayRequest) {
private fun DSLConfiguration.presentMonthlyText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
noPadTextPref(
title = DSLSettingsText.from(
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
@@ -204,7 +225,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
space(6.dp)
noPadTextPref(
title = DSLSettingsText.from(
context.getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge, request.badge.name),
context.getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name),
DSLSettingsText.CenterModifier,
DSLSettingsText.BodyLargeModifier,
DSLSettingsText.ColorModifier(ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant))
@@ -212,10 +233,10 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
private fun DSLConfiguration.presentOneTimeText(context: Context, request: GatewayRequest) {
private fun DSLConfiguration.presentOneTimeText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
noPadTextPref(
title = DSLSettingsText.from(
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
@@ -223,7 +244,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
space(6.dp)
noPadTextPref(
title = DSLSettingsText.from(
context.resources.getQuantityString(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, request.badge.name, 30),
context.resources.getQuantityString(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30),
DSLSettingsText.CenterModifier,
DSLSettingsText.BodyLargeModifier,
DSLSettingsText.ColorModifier(ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant))
@@ -231,10 +252,10 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
private fun DSLConfiguration.presentGiftText(context: Context, request: GatewayRequest) {
private fun DSLConfiguration.presentGiftText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
noPadTextPref(
title = DSLSettingsText.from(
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)

View File

@@ -2,7 +2,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
@@ -18,10 +22,10 @@ class GatewaySelectorRepository(
.map { configuration ->
val available = configuration.getAvailablePaymentMethods(currencyCode).map {
when (it) {
SubscriptionsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
SubscriptionsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
SubscriptionsConfiguration.SEPA_DEBIT -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
SubscriptionsConfiguration.IDEAL -> listOf(GatewayResponse.Gateway.IDEAL)
SubscriptionsConfiguration.PAYPAL -> listOf(InAppPaymentData.PaymentMethodType.PAYPAL)
SubscriptionsConfiguration.CARD -> listOf(InAppPaymentData.PaymentMethodType.CARD, InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
SubscriptionsConfiguration.SEPA_DEBIT -> listOf(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
SubscriptionsConfiguration.IDEAL -> listOf(InAppPaymentData.PaymentMethodType.IDEAL)
else -> listOf()
}
}.flatten().toSet()
@@ -33,8 +37,20 @@ class GatewaySelectorRepository(
}
}
fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
return Single.fromCallable {
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(
paymentMethodType = paymentMethodType
)
)
)
}.flatMap { InAppPaymentsRepository.requireInAppPayment(inAppPayment.id) }
}
data class GatewayConfiguration(
val availableGateways: Set<GatewayResponse.Gateway>,
val availableGateways: Set<InAppPaymentData.PaymentMethodType>,
val sepaEuroMaximum: FiatMoney?
)
}

View File

@@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.InAppPaymentTable
data class GatewaySelectorState(
val gatewayOrderStrategy: GatewayOrderStrategy,
val inAppPayment: InAppPaymentTable.InAppPayment,
val loading: Boolean = true,
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -9,6 +10,8 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
@@ -16,18 +19,18 @@ import org.thoughtcrime.securesms.util.rx.RxStore
class GatewaySelectorViewModel(
args: GatewaySelectorBottomSheetArgs,
repository: StripeRepository,
gatewaySelectorRepository: GatewaySelectorRepository
private val gatewaySelectorRepository: GatewaySelectorRepository
) : ViewModel() {
private val store = RxStore(
GatewaySelectorState(
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
badge = args.request.badge,
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType),
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.request.donateToSignalType)
inAppPayment = args.inAppPayment,
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.inAppPayment.type),
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.inAppPayment.type),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.inAppPayment.type),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.inAppPayment.type),
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.inAppPayment.type)
)
)
private val disposables = CompositeDisposable()
@@ -36,17 +39,18 @@ class GatewaySelectorViewModel(
init {
val isGooglePayAvailable = repository.isGooglePayAvailable().toSingleDefault(true).onErrorReturnItem(false)
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.request.currencyCode)
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.inAppPayment.data.amount!!.currencyCode)
disposables += Single.zip(isGooglePayAvailable, gatewayConfiguration, ::Pair).subscribeBy { (googlePayAvailable, gatewayConfiguration) ->
SignalStore.donationsValues().isGooglePayReady = googlePayAvailable
store.update {
it.copy(
loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.IDEAL),
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.IDEAL),
sepaEuroMaximum = gatewayConfiguration.sepaEuroMaximum
)
}
@@ -58,6 +62,10 @@ class GatewaySelectorViewModel(
disposables.clear()
}
fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
return gatewaySelectorRepository.setInAppPaymentMethodType(store.state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
}
class Factory(
private val args: GatewaySelectorBottomSheetArgs,
private val repository: StripeRepository,

View File

@@ -7,6 +7,7 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -43,14 +44,14 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
return configure {
customPref(
BadgeDisplay112.Model(
badge = args.request.badge,
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
withDisplayText = false
)
)
space(12.dp)
presentTitleAndSubtitle(requireContext(), args.request)
presentTitleAndSubtitle(requireContext(), args.inAppPayment)
space(24.dp)

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
@@ -59,15 +60,14 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
viewModel.onBeginNewAction()
when (args.action) {
DonationProcessorAction.PROCESS_NEW_DONATION -> {
viewModel.processNewDonation(args.request, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline)
viewModel.processNewDonation(args.inAppPayment!!, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline)
}
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request)
viewModel.updateSubscription(args.inAppPayment!!)
}
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
viewModel.cancelSubscription(InAppPaymentSubscriberRecord.Type.DONATION) // TODO [message-backups] Remove hardcode
}
else -> error("Unsupported action: ${args.action}")
}
}
@@ -89,7 +89,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
inAppPayment = args.inAppPayment,
status = DonationProcessorActionResult.Status.FAILURE
)
)
@@ -103,7 +103,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
inAppPayment = args.inAppPayment,
status = DonationProcessorActionResult.Status.SUCCESS
)
)
@@ -128,7 +128,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
if (result != null) {
emitter.onSuccess(result.copy(paymentId = createPaymentIntentResponse.paymentId))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
}
}
@@ -156,7 +156,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
if (result) {
emitter.onSuccess(PayPalPaymentMethodId(createPaymentIntentResponse.token))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
}
}

View File

@@ -12,29 +12,30 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
class PayPalPaymentInProgressViewModel(
private val payPalRepository: PayPalRepository,
private val monthlyDonationRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
) : ViewModel() {
companion object {
@@ -66,24 +67,24 @@ class PayPalPaymentInProgressViewModel(
}
fun processNewDonation(
request: GatewayRequest,
inAppPayment: InAppPaymentTable.InAppPayment,
routeToOneTimeConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>,
routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>
) {
Log.d(TAG, "Proceeding with donation...", true)
return when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> proceedOneTime(request, routeToOneTimeConfirmation)
DonateToSignalType.MONTHLY -> proceedMonthly(request, routeToMonthlyConfirmation)
DonateToSignalType.GIFT -> proceedOneTime(request, routeToOneTimeConfirmation)
return if (inAppPayment.type.recurring) {
proceedMonthly(inAppPayment, routeToMonthlyConfirmation)
} else {
proceedOneTime(inAppPayment, routeToOneTimeConfirmation)
}
}
fun updateSubscription(request: GatewayRequest) {
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
disposables += recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -91,28 +92,22 @@ class PayPalPaymentInProgressViewModel(
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
store.update { DonationProcessorStage.FAILED }
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
}
)
}
fun cancelSubscription() {
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
Log.d(TAG, "Beginning cancellation...", true)
store.update { DonationProcessorStage.CANCELLING }
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
disposables += recurringInAppPaymentRepository.cancelActiveSubscription(subscriberType).subscribeBy(
onComplete = {
Log.d(TAG, "Cancellation succeeded", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
MultiDeviceSubscriptionSyncRequestJob.enqueue()
monthlyDonationRepository.syncAccountRecord().subscribe()
recurringInAppPaymentRepository.syncAccountRecord().subscribe()
store.update { DonationProcessorStage.COMPLETE }
},
onError = { throwable ->
@@ -123,13 +118,13 @@ class PayPalPaymentInProgressViewModel(
}
private fun proceedOneTime(
request: GatewayRequest,
inAppPayment: InAppPaymentTable.InAppPayment,
routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>
) {
Log.d(TAG, "Proceeding with one-time payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
val verifyUser = if (request.donateToSignalType == DonateToSignalType.GIFT) {
OneTimeDonationRepository.verifyRecipientIsAllowedToReceiveAGift(request.recipientId)
val verifyUser = if (inAppPayment.type == InAppPaymentTable.Type.ONE_TIME_GIFT) {
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(RecipientId.from(inAppPayment.data.recipientId!!))
} else {
Completable.complete()
}
@@ -138,24 +133,23 @@ class PayPalPaymentInProgressViewModel(
.andThen(
payPalRepository
.createOneTimePaymentIntent(
amount = request.fiat,
badgeRecipient = request.recipientId,
badgeLevel = request.level
amount = inAppPayment.data.amount!!.toFiatMoney(),
badgeRecipient = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id,
badgeLevel = inAppPayment.data.level
)
)
.flatMap(routeToPaypalConfirmation)
.flatMap { result ->
payPalRepository.confirmOneTimePaymentIntent(
amount = request.fiat,
badgeLevel = request.level,
amount = inAppPayment.data.amount.toFiatMoney(),
badgeLevel = inAppPayment.data.level,
paypalConfirmationResult = result
)
}
.flatMapCompletable { response ->
oneTimeDonationRepository.waitForOneTimeRedemption(
gatewayRequest = request,
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
inAppPayment = inAppPayment,
paymentIntentId = response.paymentId,
donationProcessor = DonationProcessor.PAYPAL,
paymentSourceType = PaymentSourceType.PayPal
)
}
@@ -164,13 +158,7 @@ class PayPalPaymentInProgressViewModel(
onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, PaymentSourceType.PayPal, throwable)
},
onComplete = {
Log.d(TAG, "Finished one-time payment pipeline...", true)
@@ -179,28 +167,22 @@ class PayPalPaymentInProgressViewModel(
)
}
private fun proceedMonthly(request: GatewayRequest, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
Log.d(TAG, "Proceeding with monthly payment pipeline...")
val setup = monthlyDonationRepository.ensureSubscriberId()
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
.andThen(payPalRepository.createPaymentMethod())
val setup = recurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
.andThen(recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
.andThen(payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType()))
.flatMap(routeToPaypalConfirmation)
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
disposables += setup.andThen(recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
},
onComplete = {
Log.d(TAG, "Finished subscription payment pipeline...", true)
@@ -211,11 +193,11 @@ class PayPalPaymentInProgressViewModel(
class Factory(
private val payPalRepository: PayPalRepository = PayPalRepository(ApplicationDependencies.getDonationsService()),
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T
}
}
}

View File

@@ -9,13 +9,13 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toBigDecimal
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.ExternalLaunchTransactionState
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.recipients.Recipient
import kotlin.time.Duration.Companion.milliseconds
/**
* Encapsulates the data required to complete a pending external transaction
@@ -23,14 +23,14 @@ import org.thoughtcrime.securesms.recipients.RecipientId
@Parcelize
data class Stripe3DSData(
val stripeIntentAccessor: StripeIntentAccessor,
val gatewayRequest: GatewayRequest,
val inAppPayment: InAppPaymentTable.InAppPayment,
private val rawPaymentSourceType: String
) : Parcelable {
@IgnoredOnParcel
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
@IgnoredOnParcel
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY && paymentSourceType.isBankTransfer)
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (inAppPayment.type.recurring && paymentSourceType.isBankTransfer)
fun toProtoBytes(): ByteArray {
return ExternalLaunchTransactionState(
@@ -43,25 +43,27 @@ data class Stripe3DSData(
intentClientSecret = stripeIntentAccessor.intentClientSecret
),
gatewayRequest = ExternalLaunchTransactionState.GatewayRequest(
donateToSignalType = when (gatewayRequest.donateToSignalType) {
DonateToSignalType.ONE_TIME -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
DonateToSignalType.MONTHLY -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
DonateToSignalType.GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
donateToSignalType = when (inAppPayment.type) {
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentTable.Type.ONE_TIME_DONATION -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
InAppPaymentTable.Type.RECURRING_DONATION -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
InAppPaymentTable.Type.ONE_TIME_GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
InAppPaymentTable.Type.RECURRING_BACKUP -> error("Unimplemented") // TODO [message-backups] do we still need this?
},
badge = Badges.toDatabaseBadge(gatewayRequest.badge),
label = gatewayRequest.label,
price = gatewayRequest.price.toDecimalValue(),
currencyCode = gatewayRequest.currencyCode,
level = gatewayRequest.level,
recipient_id = gatewayRequest.recipientId.toLong(),
additionalMessage = gatewayRequest.additionalMessage ?: ""
badge = inAppPayment.data.badge,
label = inAppPayment.data.label,
price = inAppPayment.data.amount!!.amount,
currencyCode = inAppPayment.data.amount.currencyCode,
level = inAppPayment.data.level,
recipient_id = inAppPayment.data.recipientId?.toLong() ?: Recipient.self().id.toLong(),
additionalMessage = inAppPayment.data.additionalMessage ?: ""
),
paymentSourceType = paymentSourceType.code
).encode()
}
companion object {
fun fromProtoBytes(byteArray: ByteArray, uiSessionKey: Long): Stripe3DSData {
fun fromProtoBytes(byteArray: ByteArray): Stripe3DSData {
val proto = ExternalLaunchTransactionState.ADAPTER.decode(byteArray)
return Stripe3DSData(
stripeIntentAccessor = StripeIntentAccessor(
@@ -72,20 +74,33 @@ data class Stripe3DSData(
intentId = proto.stripeIntentAccessor.intentId,
intentClientSecret = proto.stripeIntentAccessor.intentClientSecret
),
gatewayRequest = GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = when (proto.gatewayRequest!!.donateToSignalType) {
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> DonateToSignalType.MONTHLY
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> DonateToSignalType.ONE_TIME
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> DonateToSignalType.GIFT
inAppPayment = InAppPaymentTable.InAppPayment(
id = InAppPaymentTable.InAppPaymentId(-1), // TODO [alex] -- can we start writing this in for new transactions?
type = when (proto.gatewayRequest!!.donateToSignalType) {
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> InAppPaymentTable.Type.RECURRING_DONATION
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> InAppPaymentTable.Type.ONE_TIME_DONATION
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> InAppPaymentTable.Type.ONE_TIME_GIFT
// TODO [message-backups] -- Backups?
},
badge = Badges.fromDatabaseBadge(proto.gatewayRequest.badge!!),
label = proto.gatewayRequest.label,
price = proto.gatewayRequest.price!!.toBigDecimal(),
currencyCode = proto.gatewayRequest.currencyCode,
level = proto.gatewayRequest.level,
recipientId = RecipientId.from(proto.gatewayRequest.recipient_id),
additionalMessage = proto.gatewayRequest.additionalMessage.takeIf { it.isNotBlank() }
endOfPeriod = 0.milliseconds,
updatedAt = 0.milliseconds,
insertedAt = 0.milliseconds,
notified = true,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
data = InAppPaymentData(
paymentMethodType = PaymentSourceType.fromCode(proto.paymentSourceType).toPaymentMethodType(),
badge = proto.gatewayRequest.badge,
label = proto.gatewayRequest.label,
amount = FiatValue(amount = proto.gatewayRequest.price, currencyCode = proto.gatewayRequest.currencyCode),
level = proto.gatewayRequest.level,
recipientId = null,
additionalMessage = "",
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeClientSecret = proto.stripeIntentAccessor.intentClientSecret,
stripeIntentId = proto.stripeIntentAccessor.intentId
)
)
),
rawPaymentSourceType = proto.paymentSourceType
)

View File

@@ -20,13 +20,17 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import com.google.android.material.button.MaterialButton
import org.signal.donations.PaymentSourceType
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.visible
@@ -50,6 +54,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
var result: Bundle? = null
private val lifecycleDisposable = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
@@ -57,6 +63,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
dialog!!.window!!.setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
@@ -76,7 +84,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
)
)
if (FeatureFlags.internalUser() && args.stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
if (FeatureFlags.internalUser() && args.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
val openApp = MaterialButton(requireContext()).apply {
text = "Open App"
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
@@ -98,14 +106,20 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
}
private fun handleLaunchExternal(intent: Intent) {
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
lifecycleDisposable += Completable
.fromAction {
SignalDatabase.inAppPayments.update(args.inAppPayment)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
result = bundleOf(
LAUNCHED_EXTERNAL to true
)
result = bundleOf(
LAUNCHED_EXTERNAL to true
)
startActivity(intent)
dismissAllowingStateLoss()
startActivity(intent)
dismissAllowingStateLoss()
}
}
private inner class Stripe3DSWebClient : WebViewClient() {

View File

@@ -8,10 +8,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s
import io.reactivex.rxjava3.core.Single
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.database.InAppPaymentTable
fun interface StripeNextActionHandler {
fun handle(
action: StripeApi.Secure3DSAction,
stripe3DSData: Stripe3DSData
inAppPayment: InAppPaymentTable.InAppPayment
): Single<StripeIntentAccessor>
}

View File

@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -63,13 +64,13 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
viewModel.onBeginNewAction()
when (args.action) {
DonationProcessorAction.PROCESS_NEW_DONATION -> {
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
viewModel.processNewDonation(args.inAppPayment!!, this::handleSecure3dsAction)
}
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request, args.isLongRunning)
viewModel.updateSubscription(args.inAppPayment!!)
}
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
viewModel.cancelSubscription(args.inAppPaymentType.requireSubscriberType())
}
}
}
@@ -92,7 +93,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
inAppPayment = args.inAppPayment,
status = DonationProcessorActionResult.Status.FAILURE
)
)
@@ -106,7 +107,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
inAppPayment = args.inAppPayment,
status = DonationProcessorActionResult.Status.SUCCESS
)
)
@@ -116,7 +117,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
}
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single<StripeIntentAccessor> {
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): Single<StripeIntentAccessor> {
return when (secure3dsAction) {
is StripeApi.Secure3DSAction.NotNeeded -> {
Log.d(TAG, "No 3DS action required.")
@@ -132,16 +133,16 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
} else {
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
if (didLaunchExternal) {
emitter.onError(DonationError.UserLaunchedExternalApplication(args.request.donateToSignalType.toErrorSource()))
emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource()))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
}
}
}
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, stripe3DSData))
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, inAppPayment))
emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)

View File

@@ -10,32 +10,38 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
class StripePaymentInProgressViewModel(
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
) : ViewModel() {
companion object {
@@ -69,21 +75,15 @@ class StripePaymentInProgressViewModel(
disposables.clear()
}
fun processNewDonation(request: GatewayRequest, nextActionHandler: StripeNextActionHandler) {
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, nextActionHandler: StripeNextActionHandler) {
Log.d(TAG, "Proceeding with donation...", true)
val errorSource = when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(inAppPayment.type.toErrorSource())
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(errorSource)
return when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
DonateToSignalType.GIFT -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
return if (inAppPayment.type.recurring) {
proceedMonthly(inAppPayment, paymentSourceProvider, nextActionHandler)
} else {
proceedOneTime(inAppPayment, paymentSourceProvider, nextActionHandler)
}
}
@@ -142,27 +142,31 @@ class StripePaymentInProgressViewModel(
stripePaymentData = null
}
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
val ensureSubscriberId: Completable = recurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isBankTransfer)
val setLevel: Completable = recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceProvider.paymentSourceType)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
val setup: Completable = ensureSubscriberId
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
.andThen(recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
.andThen(createAndConfirmSetupIntent)
.flatMap { secure3DSAction ->
nextActionHandler.handle(
action = secure3DSAction,
Stripe3DSData(
secure3DSAction.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
inAppPayment = inAppPayment.copy(
data = inAppPayment.data.copy(
redemption = null,
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeIntentId = secure3DSAction.stripeIntentAccessor.intentId,
stripeClientSecret = secure3DSAction.stripeIntentAccessor.intentClientSecret
)
)
)
)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
@@ -180,13 +184,7 @@ class StripePaymentInProgressViewModel(
onError = { throwable ->
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType, throwable)
},
onComplete = {
Log.d(TAG, "Finished subscription payment pipeline...", true)
@@ -196,41 +194,45 @@ class StripePaymentInProgressViewModel(
}
private fun proceedOneTime(
request: GatewayRequest,
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSourceProvider: PaymentSourceProvider,
nextActionHandler: StripeNextActionHandler
) {
Log.w(TAG, "Beginning one-time payment pipeline...", true)
val amount = request.fiat
val verifyUser = if (request.donateToSignalType == DonateToSignalType.GIFT) {
OneTimeDonationRepository.verifyRecipientIsAllowedToReceiveAGift(request.recipientId)
val amount = inAppPayment.data.amount!!.toFiatMoney()
val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id
val verifyUser = if (inAppPayment.type == InAppPaymentTable.Type.ONE_TIME_GIFT) {
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId)
} else {
Completable.complete()
}
val continuePayment: Single<StripeIntentAccessor> = verifyUser.andThen(stripeRepository.continuePayment(amount, request.recipientId, request.level, paymentSourceProvider.paymentSourceType))
val continuePayment: Single<StripeIntentAccessor> = verifyUser.andThen(stripeRepository.continuePayment(amount, recipientId, inAppPayment.data.level, paymentSourceProvider.paymentSourceType))
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider.paymentSource, ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipientId)
.flatMap { action ->
nextActionHandler
.handle(
action,
Stripe3DSData(
action.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
action = action,
inAppPayment = inAppPayment.copy(
data = inAppPayment.data.copy(
redemption = null,
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeIntentId = action.stripeIntentAccessor.intentId,
stripeClientSecret = action.stripeIntentAccessor.intentClientSecret
)
)
)
)
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
}
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
gatewayRequest = request,
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
inAppPayment = inAppPayment,
paymentIntentId = paymentIntent.intentId,
donationProcessor = DonationProcessor.STRIPE,
paymentSourceType = paymentSource.type
)
}
@@ -238,13 +240,7 @@ class StripePaymentInProgressViewModel(
onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), paymentSourceProvider.paymentSourceType)
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, paymentSourceProvider.paymentSourceType, throwable)
},
onComplete = {
Log.w(TAG, "Completed one-time payment pipeline...", true)
@@ -253,14 +249,14 @@ class StripePaymentInProgressViewModel(
)
}
fun cancelSubscription() {
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
Log.d(TAG, "Beginning cancellation...", true)
store.update { DonationProcessorStage.CANCELLING }
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
disposables += recurringInAppPaymentRepository.cancelActiveSubscription(subscriberType).subscribeBy(
onComplete = {
Log.d(TAG, "Cancellation succeeded", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
MultiDeviceSubscriptionSyncRequestJob.enqueue()
stripeRepository.scheduleSyncForAccountRecordChange()
store.update { DonationProcessorStage.COMPLETE }
@@ -272,10 +268,13 @@ class StripePaymentInProgressViewModel(
)
}
fun updateSubscription(request: GatewayRequest, isLongRunning: Boolean) {
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, isLongRunning))
disposables += recurringInAppPaymentRepository
.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())
.andThen(recurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType()))
.flatMapCompletable { paymentSourceType -> recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType) }
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -283,14 +282,11 @@ class StripePaymentInProgressViewModel(
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.Stripe.GooglePay)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
store.update { DonationProcessorStage.FAILED }
SignalExecutors.BOUNDED_IO.execute {
val paymentSourceType = InAppPaymentsRepository.getLatestPaymentMethodType(inAppPayment.type.requireSubscriberType()).toPaymentSourceType()
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceType, throwable)
}
}
)
}
@@ -309,11 +305,11 @@ class StripePaymentInProgressViewModel(
class Factory(
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T
}
}
}

View File

@@ -56,17 +56,16 @@ import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -90,13 +89,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPayment.id)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
@@ -111,16 +104,16 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
val donateLabel = remember(args.inAppPayment) {
if (args.inAppPayment.type.recurring) { // TODO [message-requests] backups copy
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney())
)
}
}
@@ -154,15 +147,16 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
args.inAppPayment,
args.inAppPayment.type
)
)
}
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to inAppPayment))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)

View File

@@ -57,17 +57,16 @@ import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -81,7 +80,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
private val args: IdealTransferDetailsFragmentArgs by navArgs()
private val viewModel: IdealTransferDetailsViewModel by viewModel {
IdealTransferDetailsViewModel(args.request.donateToSignalType == DonateToSignalType.MONTHLY)
IdealTransferDetailsViewModel(args.inAppPayment.type.recurring)
}
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
@@ -94,13 +93,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPayment.id)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
@@ -120,22 +113,22 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
override fun FragmentContent() {
val state by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
val donateLabel = remember(args.inAppPayment) {
if (args.inAppPayment.type.recurring) { // TODO [message-request] -- Handle backups
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney())
)
}
}
val idealDirections = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
val idealDirections = remember(args.inAppPayment) {
if (args.inAppPayment.type.recurring) { // TODO [message-request] -- Handle backups
R.string.IdealTransferDetailsFragment__enter_your_bank
} else {
R.string.IdealTransferDetailsFragment__enter_your_bank_details_one_time
@@ -164,12 +157,13 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
findNavController().safeNavigate(
IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
args.inAppPayment,
args.inAppPayment.type
)
)
}
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
if (args.inAppPayment.type.recurring) { // TODO [message-requests] -- handle backup
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_s, getString(state.idealBank!!.getUIValues().name)))
.setMessage(R.string.IdealTransferDetailsFragment__monthly_ideal_warning)
@@ -191,8 +185,8 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
})
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to inAppPayment))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)

View File

@@ -58,9 +58,9 @@ import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
@@ -112,13 +112,13 @@ class BankTransferMandateFragment : ComposeFragment() {
}
private fun onContinueClick() {
if (args.response.gateway == GatewayResponse.Gateway.SEPA_DEBIT) {
if (args.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT) {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.response.request)
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.inAppPayment)
)
} else {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.response.request)
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.inAppPayment)
)
}
}

View File

@@ -9,16 +9,20 @@ import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeError
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
/**
* @deprecated Replaced with InAppDonationData.Error
*
* This needs to remain until all the old jobs are through people's systems (90 days from release + timeout)
*/
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)
}
@@ -38,8 +42,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
*/
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
class FailedToFetchProfile(cause: Throwable) : GiftRecipientVerificationError(Exception("Failed to fetch recipient profile.", cause))
}
/**
@@ -106,7 +108,7 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
* Timeout elapsed while the user was waiting for badge redemption to complete for a long-running payment.
* This is not an indication that redemption failed, just that it could take a few days to process the payment.
*/
class DonationPending(source: DonationErrorSource, val gatewayRequest: GatewayRequest) : BadgeRedemptionError(source, Exception("Long-running donation is still pending."))
class DonationPending(source: DonationErrorSource, val inAppPayment: InAppPaymentTable.InAppPayment) : BadgeRedemptionError(source, Exception("Long-running donation is still pending."))
/**
* Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that
@@ -133,20 +135,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
source to PublishSubject.create()
}
private val donationErrorsSubjectUiSessionMap: MutableMap<Long, Subject<DonationError>> = mutableMapOf()
@JvmStatic
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
return donationErrorSubjectSourceMap[donationErrorSource]!!
}
fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable<DonationError> {
val subject: Subject<DonationError> = donationErrorsSubjectUiSessionMap[uiSessionKey] ?: PublishSubject.create()
donationErrorsSubjectUiSessionMap[uiSessionKey] = subject
return subject
}
@JvmStatic
fun DonationError.toDonationErrorValue(): DonationErrorValue {
return when (this) {
@@ -182,7 +175,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
@JvmOverloads
fun routeBackgroundError(
context: Context,
uiSessionKey: Long,
error: DonationError,
suppressNotification: Boolean = true
) {
@@ -191,17 +183,12 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
return
}
val subject: Subject<DonationError>? = donationErrorsSubjectUiSessionMap[uiSessionKey]
when {
subject != null && subject.hasObservers() -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
subject.onNext(error)
}
suppressNotification -> {
Log.i(TAG, "Suppressing notification for error.", error)
}
else -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
Log.i(TAG, "Routing background donation error to notification", error)
DonationErrorNotifications.displayErrorNotification(context, error)
}
}
@@ -211,8 +198,7 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
* 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) {
private fun routeDonationError(context: Context, error: DonationError) {
val subject: Subject<DonationError> = donationErrorSubjectSourceMap[error.source]!!
when {
subject.hasObservers() -> {
@@ -226,11 +212,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: 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 or via PayPal, when we know for sure that no
@@ -260,21 +241,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
}
@JvmStatic
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
@JvmStatic
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
@JvmStatic
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
@JvmStatic
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
@JvmStatic
fun donationPending(source: DonationErrorSource, gatewayRequest: GatewayRequest) = BadgeRedemptionError.DonationPending(source, gatewayRequest)
@JvmStatic
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)

View File

@@ -5,6 +5,7 @@ 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.database.InAppPaymentTable
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -12,6 +13,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
* Donation Error Dialogs.
*/
object DonationErrorDialogs {
/**
* Displays a dialog, and returns a handle to it for dismissal.
*/
@@ -36,6 +38,30 @@ object DonationErrorDialogs {
return builder.show()
}
/**
* Displays a dialog, and returns a handle to it for dismissal.
*/
fun show(context: Context, inAppPayment: InAppPaymentTable.InAppPayment, callback: DialogCallback): DialogInterface {
val builder = MaterialAlertDialogBuilder(context)
builder.setOnDismissListener { callback.onDialogDismissed() }
val params = DonationErrorParams.create(context, inAppPayment, callback)
builder.setTitle(params.title)
.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()
}
abstract class DialogCallback : DonationErrorParams.Callback<Unit> {
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<Unit>? {

View File

@@ -6,6 +6,10 @@ import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toInAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
class DonationErrorParams<V> private constructor(
@StringRes val title: Int,
@@ -25,46 +29,61 @@ class DonationErrorParams<V> private constructor(
callback: Callback<V>
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable, callback)
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable.method, throwable.declineCode, callback)
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable.method, throwable.failureCode, callback)
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable.code, callback)
is DonationError.PaymentSetupError -> getGenericPaymentSetupErrorParams(context, callback)
is DonationError.BadgeRedemptionError.DonationPending -> DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
positiveAction = callback.onOk(context),
negativeAction = null
)
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
)
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams(
title = R.string.DonationsErrors__failed_to_validate_badge,
message = R.string.DonationsErrors__could_not_validate,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.DonationPending -> getStillProcessingErrorParams(context, callback)
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> getStillProcessingErrorParams(context, callback)
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> getBadgeCredentialValidationErrorParams(context, callback)
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable.source.toInAppPaymentType(), callback)
else -> getGenericRedemptionError(context, InAppPaymentTable.Type.ONE_TIME_DONATION, callback)
}
}
private fun <V> getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback<V>): DonationErrorParams<V> {
return when (genericError.source) {
DonationErrorSource.GIFT -> DonationErrorParams(
fun <V> create(
context: Context,
inAppPayment: InAppPaymentTable.InAppPayment,
callback: Callback<V>
): DonationErrorParams<V> {
return when (inAppPayment.data.error?.type) {
InAppPaymentData.Error.Type.UNKNOWN -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.INVALID_GIFT_RECIPIENT -> getVerificationErrorParams(context, callback)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.INVALID_CURRENCY -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.PAYMENT_SETUP -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.STRIPE_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR -> getStripeDeclinedErrorParams(
context = context,
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
declineCode = StripeDeclineCode.getFromCode(inAppPayment.data.error.data_),
callback = callback
)
InAppPaymentData.Error.Type.STRIPE_FAILURE -> getStripeFailureCodeErrorParams(
context = context,
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
failureCode = StripeFailureCode.getFromCode(inAppPayment.data.error.data_),
callback = callback
)
InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR -> getPayPalDeclinedErrorParams(
context = context,
payPalDeclineCode = PayPalDeclineCode.KnownCode.fromCode(inAppPayment.data.error.data_!!.toInt())!!,
callback = callback
)
InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION -> getBadgeCredentialValidationErrorParams(context, callback)
InAppPaymentData.Error.Type.REDEMPTION -> getGenericRedemptionError(context, inAppPayment.type, callback)
null -> error("No error in data!")
}
}
private fun <V> getGenericRedemptionError(context: Context, type: InAppPaymentTable.Type, callback: Callback<V>): DonationErrorParams<V> {
return when (type) {
InAppPaymentTable.Type.ONE_TIME_GIFT -> DonationErrorParams(
title = R.string.DonationsErrors__donation_failed,
message = R.string.DonationsErrors__your_payment_was_processed_but,
positiveAction = callback.onContactSupport(context),
@@ -80,65 +99,65 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
return when (verificationError) {
is DonationError.GiftRecipientVerificationError.FailedToFetchProfile -> DonationErrorParams(
title = R.string.DonationsErrors__cannot_send_donation,
message = R.string.DonationsErrors__your_donation_could_not_be_sent,
positiveAction = callback.onOk(context),
negativeAction = null
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__cannot_send_donation,
message = R.string.DonationsErrors__this_user_cant_receive_donations_until,
positiveAction = callback.onOk(context),
negativeAction = null
)
}
private fun <V> getVerificationErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__cannot_send_donation,
message = R.string.DonationsErrors__this_user_cant_receive_donations_until,
positiveAction = callback.onOk(context),
negativeAction = null
)
}
private fun <V> getPayPalDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.PayPalDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
return when (declinedError.code) {
private fun <V> getPayPalDeclinedErrorParams(
context: Context,
payPalDeclineCode: PayPalDeclineCode.KnownCode,
callback: Callback<V>
): DonationErrorParams<V> {
return when (payPalDeclineCode) {
PayPalDeclineCode.KnownCode.DECLINED -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank_for_more_information_if_this_was_a_paypal)
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
}
}
private fun <V> getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
if (!declinedError.method.hasDeclineCodeSupport()) {
private fun <V> getStripeDeclinedErrorParams(
context: Context,
paymentSourceType: PaymentSourceType.Stripe,
declineCode: StripeDeclineCode,
callback: Callback<V>
): DonationErrorParams<V> {
if (!paymentSourceType.hasDeclineCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
}
fun unexpectedDeclinedError(declinedError: DonationError.PaymentSetupError.StripeDeclinedError): Nothing {
error("Unexpected declined error: ${declinedError.declineCode} during ${declinedError.method} processing.")
fun unexpectedDeclinedError(declineCode: StripeDeclineCode, paymentSourceType: PaymentSourceType.Stripe): Nothing {
error("Unexpected declined error: $declineCode during $paymentSourceType processing.")
}
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (declinedError.method) {
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> this::getTryCreditCardAgainParams
PaymentSourceType.Stripe.GooglePay -> this::getGoToGooglePayParams
else -> this::getLearnMoreParams
}
return when (declinedError.declineCode) {
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
return when (declineCode) {
is StripeDeclineCode.Known -> when (declineCode.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
@@ -146,30 +165,30 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
@@ -177,40 +196,40 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams(
context,
callback,
when (declinedError.method) {
when (paymentSourceType) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
else -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
}
)
@@ -224,15 +243,20 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getStripeFailureCodeErrorParams(context: Context, failureCodeError: DonationError.PaymentSetupError.StripeFailureCodeError, callback: Callback<V>): DonationErrorParams<V> {
if (!failureCodeError.method.hasFailureCodeSupport()) {
private fun <V> getStripeFailureCodeErrorParams(
context: Context,
paymentSourceType: PaymentSourceType.Stripe,
failureCode: StripeFailureCode,
callback: Callback<V>
): DonationErrorParams<V> {
if (!paymentSourceType.hasFailureCodeSupport()) {
return getGenericPaymentSetupErrorParams(context, callback)
}
return when (failureCodeError.failureCode) {
return when (failureCode) {
is StripeFailureCode.Known -> {
val errorText = failureCodeError.failureCode.mapToErrorStringResource()
when (failureCodeError.failureCode.code) {
val errorText = failureCode.mapToErrorStringResource()
when (failureCode.code) {
StripeFailureCode.Code.REFER_TO_CUSTOMER -> getTryBankTransferAgainParams(context, callback, errorText)
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, errorText)
StripeFailureCode.Code.DEBIT_DISPUTED -> getLearnMoreParams(context, callback, errorText)
@@ -248,10 +272,29 @@ class DonationErrorParams<V> private constructor(
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, errorText)
}
}
is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback)
}
}
private fun <V> getStillProcessingErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
positiveAction = callback.onOk(context),
negativeAction = null
)
}
private fun <V> getBadgeCredentialValidationErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__failed_to_validate_badge,
message = R.string.DonationsErrors__could_not_validate,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
private fun <V> getGenericPaymentSetupErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,

View File

@@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
@@ -33,6 +32,7 @@ object ActiveSubscriptionPreference {
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.RedemptionState,
val activeSubscription: ActiveSubscription.Subscription?,
val subscriberRequiresCancel: Boolean,
val onContactSupport: () -> Unit,
val onPendingClick: (FiatMoney) -> Unit
) : PreferenceModel<Model>() {
@@ -104,7 +104,7 @@ object ActiveSubscriptionPreference {
}
private fun presentFailureState(model: Model) {
if (model.activeSubscription?.isFailedPayment == true || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
if (model.activeSubscription?.isFailedPayment == true || model.subscriberRequiresCancel) {
presentPaymentFailureState(model)
} else {
presentRedemptionFailureState(model)

View File

@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -16,6 +18,8 @@ import java.util.concurrent.TimeUnit
/**
* Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions or one time payments.
*
* @deprecated This object is deprecated and will be removed once we are sure all jobs have drained.
*/
object DonationRedemptionJobWatcher {
@@ -24,7 +28,6 @@ object DonationRedemptionJobWatcher {
ONE_TIME
}
@JvmStatic
@WorkerThread
fun hasPendingRedemptionJob(): Boolean {
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION).isInProgress() || getDonationRedemptionJobStatus(RedemptionType.ONE_TIME).isInProgress()
@@ -113,9 +116,9 @@ object DonationRedemptionJobWatcher {
}
return DonationSerializationHelper.createPendingOneTimeDonationProto(
badge = stripe3DSData.gatewayRequest.badge,
badge = Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
paymentSourceType = stripe3DSData.paymentSourceType,
amount = stripe3DSData.gatewayRequest.fiat
amount = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney()
).copy(
timestamp = createTime,
pendingVerification = true,
@@ -130,8 +133,8 @@ object DonationRedemptionJobWatcher {
return NonVerifiedMonthlyDonation(
timestamp = createTime,
price = stripe3DSData.gatewayRequest.fiat,
level = stripe3DSData.gatewayRequest.level.toInt(),
price = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney(),
level = stripe3DSData.inAppPayment.data.level.toInt(),
checkedVerification = runAttempt > 0
)
}

View File

@@ -21,12 +21,12 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -70,7 +70,7 @@ class ManageDonationsFragment :
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
ManageDonationsViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
ManageDonationsViewModel.Factory(RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()))
}
)
@@ -167,7 +167,7 @@ class ManageDonationsFragment :
primaryWrappedButton(
text = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_to_signal),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.ONE_TIME))
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.ONE_TIME_DONATION))
}
)
@@ -274,6 +274,7 @@ class ManageDonationsFragment :
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onPendingClick = {
displayPendingDialog(it)
}
@@ -295,6 +296,7 @@ class ManageDonationsFragment :
redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS,
onContactSupport = {},
activeSubscription = null,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onPendingClick = {}
)
)
@@ -318,7 +320,7 @@ class ManageDonationsFragment :
icon = DSLSettingsIcon.from(R.drawable.symbol_person_24),
isEnabled = state.getMonthlyDonorRedemptionState() != ManageDonationsState.RedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.RECURRING_DONATION))
}
)
@@ -422,6 +424,7 @@ class ManageDonationsFragment :
}
.show()
}
else -> {
val message = if (isIdeal) {
R.string.DonationsErrors__your_ideal_couldnt_be_processed
@@ -445,6 +448,6 @@ class ManageDonationsFragment :
}
override fun onMakeAMonthlyDonation() {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.RECURRING_DONATION))
}
}

View File

@@ -13,6 +13,7 @@ data class ManageDonationsState(
val availableSubscriptions: List<Subscription> = emptyList(),
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
val subscriberRequiresCancel: Boolean = false,
private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE
) {

View File

@@ -12,8 +12,11 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -23,7 +26,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Optional
class ManageDonationsViewModel(
private val subscriptionsRepository: MonthlyDonationRepository
private val subscriptionsRepository: RecurringInAppPaymentRepository
) : ViewModel() {
private val store = Store(ManageDonationsState())
@@ -62,7 +65,15 @@ class ManageDonationsViewModel(
disposables.clear()
val levelUpdateOperationEdges: Observable<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
disposables += Single.fromCallable {
InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION)
}.subscribeBy { requiresCancel ->
store.update {
it.copy(subscriberRequiresCancel = requiresCancel)
}
}
disposables += Recipient.observable(Recipient.self().id).map { it.badges }.subscribeBy { badges ->
store.update { state ->
@@ -76,7 +87,7 @@ class ManageDonationsViewModel(
store.update { it.copy(hasReceipts = hasReceipts) }
}
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { redemptionStatus ->
disposables += InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.RECURRING_DONATION).subscribeBy { redemptionStatus ->
store.update { manageDonationsState ->
manageDonationsState.copy(
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
@@ -87,7 +98,7 @@ class ManageDonationsViewModel(
disposables += Observable.combineLatest(
SignalStore.donationsValues().observablePendingOneTimeDonation,
DonationRedemptionJobWatcher.watchOneTimeRedemption()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.ONE_TIME_DONATION)
) { pendingFromStore, pendingFromJob ->
if (pendingFromStore.isPresent) {
pendingFromStore
@@ -145,7 +156,7 @@ class ManageDonationsViewModel(
}
class Factory(
private val subscriptionsRepository: MonthlyDonationRepository
private val subscriptionsRepository: RecurringInAppPaymentRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!

View File

@@ -19,7 +19,10 @@ object PayPalButton {
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
override fun bind(model: Model) {
binding.paypalButton.isEnabled = model.isEnabled
binding.paypalButton.setOnClickListener { model.onClick() }
binding.paypalButton.setOnClickListener {
binding.paypalButton.isEnabled = false
model.onClick()
}
}
}
}

View File

@@ -18,8 +18,10 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
@@ -28,7 +30,6 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -236,7 +237,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
if (recipient.isSelf) {
sectionHeaderPref(DSLSettingsText.from("Donations"))
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
// TODO [alex] - DB on main thread!
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
val summary = if (subscriber != null) {
"""currency code: ${subscriber.currencyCode}
|subscriber id: ${subscriber.subscriberId.serialize()}

View File

@@ -160,18 +160,20 @@ class DSLConfiguration {
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
disableOnClick: Boolean = false,
onClick: () -> Unit
) {
val preference = Button.Model.Primary(text, icon, isEnabled, onClick)
val preference = Button.Model.Primary(text, icon, isEnabled, disableOnClick, onClick)
children.add(preference)
}
fun primaryWrappedButton(
text: DSLSettingsText,
isEnabled: Boolean = true,
disableOnClick: Boolean = false,
onClick: () -> Unit
) {
val preference = Button.Model.PrimaryWrapped(text, null, isEnabled, onClick)
val preference = Button.Model.PrimaryWrapped(text, null, isEnabled, disableOnClick, onClick)
children.add(preference)
}
@@ -179,9 +181,10 @@ class DSLConfiguration {
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
disableOnClick: Boolean = false,
onClick: () -> Unit
) {
val preference = Button.Model.Tonal(text, icon, isEnabled, onClick)
val preference = Button.Model.Tonal(text, icon, isEnabled, disableOnClick, onClick)
children.add(preference)
}
@@ -189,9 +192,10 @@ class DSLConfiguration {
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
disableOnClick: Boolean = false,
onClick: () -> Unit
) {
val preference = Button.Model.TonalWrapped(text, icon, isEnabled, onClick)
val preference = Button.Model.TonalWrapped(text, icon, isEnabled, disableOnClick, onClick)
children.add(preference)
}
@@ -199,9 +203,10 @@ class DSLConfiguration {
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
disableOnClick: Boolean = false,
onClick: () -> Unit
) {
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, disableOnClick, onClick)
children.add(preference)
}

View File

@@ -24,6 +24,7 @@ object Button {
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
val disableOnClick: Boolean,
val onClick: () -> Unit
) : PreferenceModel<T>(
title = title,
@@ -37,8 +38,9 @@ object Button {
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
disableOnClick: Boolean,
onClick: () -> Unit
) : Model<Primary>(title, icon, isEnabled, onClick)
) : Model<Primary>(title, icon, isEnabled, disableOnClick, onClick)
/**
* Large primary button with width set to wrap_content
@@ -47,29 +49,33 @@ object Button {
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
disableOnClick: Boolean,
onClick: () -> Unit
) : Model<PrimaryWrapped>(title, icon, isEnabled, onClick)
) : Model<PrimaryWrapped>(title, icon, isEnabled, disableOnClick, onClick)
class Tonal(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
disableOnClick: Boolean,
onClick: () -> Unit
) : Model<Tonal>(title, icon, isEnabled, onClick)
) : Model<Tonal>(title, icon, isEnabled, disableOnClick, onClick)
class TonalWrapped(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
disableOnClick: Boolean,
onClick: () -> Unit
) : Model<TonalWrapped>(title, icon, isEnabled, onClick)
) : Model<TonalWrapped>(title, icon, isEnabled, disableOnClick, onClick)
class SecondaryNoOutline(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
disableOnClick: Boolean,
onClick: () -> Unit
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
) : Model<SecondaryNoOutline>(title, icon, isEnabled, disableOnClick, onClick)
}
class ViewHolder<T : Model<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
@@ -79,6 +85,7 @@ object Button {
override fun bind(model: T) {
button.text = model.title?.resolve(context)
button.setOnClickListener {
button.isEnabled = model.isEnabled && !model.disableOnClick
model.onClick()
}
button.icon = model.icon?.resolve(context)