Refactor a large portion of the payments code to prep it for PayPal support.
This commit is contained in:
parent
c563ef27da
commit
9d71c4df81
39 changed files with 839 additions and 779 deletions
|
@ -11,14 +11,14 @@ import io.reactivex.rxjava3.subjects.Subject
|
|||
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.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
|
||||
/**
|
||||
* Activity which houses the gift flow.
|
||||
*/
|
||||
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
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.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
|
@ -17,6 +25,10 @@ import java.util.Locale
|
|||
*/
|
||||
class GiftFlowRepository {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftFlowRepository::class.java)
|
||||
}
|
||||
|
||||
fun getGiftBadge(): Single<Pair<Long, Badge>> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
|
@ -44,4 +56,37 @@ class GiftFlowRepository {
|
|||
.mapValues { (currency, price) -> FiatMoney(price, currency) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the given recipient is a supported target for a gift.
|
||||
*/
|
||||
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
|
||||
if (recipient.isSelf) {
|
||||
Log.d(TAG, "Cannot send a gift to self.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
}
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
|
||||
try {
|
||||
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
|
||||
if (!profile.profile.capabilities.isGiftBadges) {
|
||||
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
} else {
|
||||
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
|
||||
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,11 +10,13 @@ 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.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
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.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
@ -30,7 +32,13 @@ class GiftFlowStartFragment : DSLSettingsFragment(
|
|||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() },
|
||||
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
|
||||
factoryProducer = {
|
||||
GiftFlowViewModel.Factory(
|
||||
GiftFlowRepository(),
|
||||
requireListener<DonationPaymentComponent>().stripeRepository,
|
||||
OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
|
|
@ -23,7 +23,8 @@ import org.signal.donations.StripeIntentAccessor
|
|||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
|
@ -38,8 +39,9 @@ import java.util.Currency
|
|||
* Maintains state as a user works their way through the gift flow.
|
||||
*/
|
||||
class GiftFlowViewModel(
|
||||
val repository: GiftFlowRepository,
|
||||
val donationPaymentRepository: DonationPaymentRepository
|
||||
private val giftFlowRepository: GiftFlowRepository,
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var giftToPurchase: Gift? = null
|
||||
|
@ -87,7 +89,7 @@ class GiftFlowViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftPricing().subscribe { giftPrices ->
|
||||
disposables += giftFlowRepository.getGiftPricing().subscribe { giftPrices ->
|
||||
store.update {
|
||||
it.copy(
|
||||
giftPrices = giftPrices,
|
||||
|
@ -96,7 +98,7 @@ class GiftFlowViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftBadge().subscribeBy(
|
||||
disposables += giftFlowRepository.getGiftBadge().subscribeBy(
|
||||
onSuccess = { (giftLevel, giftBadge) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
|
@ -139,12 +141,12 @@ class GiftFlowViewModel(
|
|||
this.giftToPurchase = Gift(giftLevel, giftPrice)
|
||||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
|
||||
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
|
||||
disposables += giftFlowRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) }
|
||||
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
|
||||
stripeRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
|
||||
},
|
||||
onError = this::onPaymentFlowError
|
||||
)
|
||||
|
@ -160,7 +162,7 @@ class GiftFlowViewModel(
|
|||
|
||||
val recipient = store.state.recipient?.id
|
||||
|
||||
donationPaymentRepository.onActivityResult(
|
||||
stripeRepository.onActivityResult(
|
||||
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
|
@ -169,13 +171,13 @@ class GiftFlowViewModel(
|
|||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
val continuePayment: Single<StripeIntentAccessor> = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level)
|
||||
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(gift.price, recipient, gift.level)
|
||||
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
.flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts.
|
||||
.andThen(donationPaymentRepository.waitForOneTimeRedemption(gift.price, paymentIntent, recipient, store.state.additionalMessage?.toString(), gift.level))
|
||||
.andThen(oneTimeDonationRepository.waitForOneTimeRedemption(gift.price, paymentIntent.intentId, recipient, store.state.additionalMessage?.toString(), gift.level))
|
||||
}.subscribeBy(
|
||||
onError = this@GiftFlowViewModel::onPaymentFlowError,
|
||||
onComplete = {
|
||||
|
@ -249,13 +251,15 @@ class GiftFlowViewModel(
|
|||
|
||||
class Factory(
|
||||
private val repository: GiftFlowRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(
|
||||
GiftFlowViewModel(
|
||||
repository,
|
||||
donationPaymentRepository
|
||||
stripeRepository,
|
||||
oneTimeDonationRepository
|
||||
)
|
||||
) as T
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
|||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
|
@ -21,7 +21,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
|||
|
||||
private val viewModel: BecomeASustainerViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BecomeASustainerViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
BecomeASustainerViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -7,10 +7,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: MonthlyDonationRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(BecomeASustainerState())
|
||||
|
||||
|
@ -37,7 +37,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository
|
|||
private val TAG = Log.tag(BecomeASustainerViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
|
||||
class Factory(private val subscriptionsRepository: MonthlyDonationRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
|
|||
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.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
@ -31,7 +31,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
|||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
|||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
|
@ -23,7 +23,7 @@ private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
|||
|
||||
class BadgesOverviewViewModel(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
|
@ -89,7 +89,7 @@ class BadgesOverviewViewModel(
|
|||
|
||||
class Factory(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
|
||||
|
|
|
@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.R
|
|||
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.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
|
@ -34,7 +34,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
|||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import io.reactivex.rxjava3.subjects.Subject
|
|||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface DonationPaymentComponent {
|
||||
val donationPaymentRepository: DonationPaymentRepository
|
||||
val stripeRepository: StripeRepository
|
||||
val googlePayResultPublisher: Subject<GooglePayResult>
|
||||
|
||||
@Parcelize
|
||||
|
|
|
@ -1,480 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
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.util.Environment
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Manages bindings with payment APIs
|
||||
*
|
||||
* Steps for setting up payments for a subscription:
|
||||
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
|
||||
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
|
||||
* 1. Create a SetupIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 1. Confirm the SetupIntent via the Stripe API
|
||||
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
|
||||
*
|
||||
* For Boosts and Gifts:
|
||||
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
|
||||
* 1. Create a PaymentIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 1. Confirm the PaymentIntent via the Stripe API
|
||||
*/
|
||||
class DonationPaymentRepository(activity: Activity) : 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())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
}
|
||||
|
||||
fun scheduleSyncForAccountRecordChange() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
scheduleSyncForAccountRecordChangeSync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSyncForAccountRecordChangeSync() {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
Log.d(TAG, "Requesting a token from google pay...")
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
Log.d(TAG, "Processing possible google pay result...")
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the given recipient is a supported target for a gift.
|
||||
*/
|
||||
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
|
||||
if (recipient.isSelf) {
|
||||
Log.d(TAG, "Cannot send a gift to self.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
}
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
|
||||
try {
|
||||
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
|
||||
if (!profile.profile.capabilities.isGiftBadges) {
|
||||
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
} else {
|
||||
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
|
||||
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param price The amount to charce the local user
|
||||
* @param badgeRecipient Who will be getting the badge
|
||||
*/
|
||||
fun continuePayment(
|
||||
price: FiatMoney,
|
||||
badgeRecipient: RecipientId,
|
||||
badgeLevel: Long,
|
||||
): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel)
|
||||
.onErrorResumeNext {
|
||||
if (it is DonationError) {
|
||||
Single.error(it)
|
||||
} else {
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
Single.error(DonationError.getPaymentSetupError(errorSource, it))
|
||||
}
|
||||
}
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
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.Success -> Single.just(result.paymentIntent)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
return stripeApi.createSetupIntent()
|
||||
.flatMap { result ->
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSubscription(): Completable {
|
||||
Log.d(TAG, "Canceling active subscription...", true)
|
||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService()
|
||||
.cancelSubscription(localSubscriber.subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
|
||||
.ignoreElement()
|
||||
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(): Completable {
|
||||
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
|
||||
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.putSubscription(subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||
.doOnComplete {
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
|
||||
SignalStore
|
||||
.donationsValues()
|
||||
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
|
||||
|
||||
scheduleSyncForAccountRecordChangeSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmPayment(
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
paymentIntent: StripeIntentAccessor,
|
||||
badgeRecipient: RecipientId
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
.onErrorResumeNext {
|
||||
Single.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForOneTimeRedemption(
|
||||
price: FiatMoney,
|
||||
paymentIntent: StripeIntentAccessor,
|
||||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long,
|
||||
): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
val donationReceiptRecord = if (isBoost) {
|
||||
DonationReceiptRecord.createForBoost(price)
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(price)
|
||||
}
|
||||
|
||||
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
|
||||
|
||||
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntent)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
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(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
|
||||
return waitOnRedemption
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize(),
|
||||
SubscriptionReceiptRequestResponseJob.MUTEX
|
||||
)
|
||||
}
|
||||
.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()
|
||||
scheduleSyncForAccountRecordChange()
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
Completable.complete()
|
||||
} else {
|
||||
if (it.applicationError.isPresent) {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true)
|
||||
SignalStore.donationsValues().clearLevelOperations()
|
||||
} else {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
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.SUBSCRIPTION))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Subscription request response job timed out.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Subscription request response interrupted.", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
}.doOnError {
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
|
||||
if (levelUpdateOperation == null) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Created a new operation for $subscriptionLevel")
|
||||
newOperation
|
||||
} else {
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Reusing operation for $subscriptionLevel")
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
|
||||
}
|
||||
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}.doOnSuccess {
|
||||
Log.d(TAG, "Got payment intent from Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching setup intent from Signal service...")
|
||||
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
|
||||
.flatMap {
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createSubscriptionPaymentMethod(it.subscriberId)
|
||||
}
|
||||
}
|
||||
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
Log.d(TAG, "Got setup intent from Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
// We need to get the status and payment id from the intent.
|
||||
|
||||
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
|
||||
return Single.fromCallable {
|
||||
when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
}
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<StripeApi.PaymentSource> {
|
||||
Log.d(TAG, "Creating credit card payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromCardData(cardData).map {
|
||||
when (it) {
|
||||
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason)
|
||||
is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DonationPaymentRepository::class.java)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
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.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
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.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
private val TAG = Log.tag(MonthlyDonationRepository::class.java)
|
||||
|
||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
return if (localSubscription != null) {
|
||||
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||
} else {
|
||||
Single.just(ActiveSubscription.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptions(): Single<List<Subscription>> = Single
|
||||
.fromCallable { donationsService.getSubscriptionLevels(Locale.getDefault()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
|
||||
.map { subscriptionLevels ->
|
||||
subscriptionLevels.levels.map { (code, level) ->
|
||||
Subscription(
|
||||
id = code,
|
||||
name = level.name,
|
||||
badge = Badges.fromServiceBadge(level.badge),
|
||||
prices = level.currencies.filter {
|
||||
PlatformCurrencyUtil
|
||||
.getAvailableCurrencyCodes()
|
||||
.contains(it.key)
|
||||
}.map { (currencyCode, price) ->
|
||||
FiatMoney(price, Currency.getInstance(currencyCode))
|
||||
}.toSet(),
|
||||
level = code.toInt()
|
||||
)
|
||||
}.sortedBy {
|
||||
it.level
|
||||
}
|
||||
}
|
||||
|
||||
fun syncAccountRecord(): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(): Completable {
|
||||
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
|
||||
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
return Single
|
||||
.fromCallable {
|
||||
donationsService.putSubscription(subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||
.doOnComplete {
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
|
||||
SignalStore
|
||||
.donationsValues()
|
||||
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
|
||||
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSubscription(): Completable {
|
||||
Log.d(TAG, "Canceling active subscription...", true)
|
||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
return Single
|
||||
.fromCallable {
|
||||
donationsService.cancelSubscription(localSubscriber.subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
|
||||
.ignoreElement()
|
||||
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
|
||||
}
|
||||
|
||||
fun cancelActiveSubscriptionIfNecessary(): Completable {
|
||||
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
|
||||
if (it) {
|
||||
Log.d(TAG, "Cancelling active subscription...", true)
|
||||
cancelActiveSubscription().doOnComplete {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize(),
|
||||
SubscriptionReceiptRequestResponseJob.MUTEX
|
||||
)
|
||||
}
|
||||
.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()
|
||||
syncAccountRecord().subscribe()
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
Completable.complete()
|
||||
} else {
|
||||
if (it.applicationError.isPresent) {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true)
|
||||
SignalStore.donationsValues().clearLevelOperations()
|
||||
} else {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
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.SUBSCRIPTION))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Subscription request response job timed out.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Subscription request response interrupted.", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
}.doOnError {
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
|
||||
if (levelUpdateOperation == null) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Created a new operation for $subscriptionLevel")
|
||||
newOperation
|
||||
} else {
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Reusing operation for $subscriptionLevel")
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
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.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OneTimeDonationRepository(private val donationsService: DonationsService) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(OneTimeDonationRepository::class.java)
|
||||
}
|
||||
|
||||
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
|
||||
return Single.fromCallable { donationsService.boostAmounts }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
||||
.map { result ->
|
||||
result
|
||||
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
|
||||
.mapKeys { (code, _) -> Currency.getInstance(code) }
|
||||
.mapValues { (currency, prices) -> prices.map { Boost(FiatMoney(it, currency)) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun getBoostBadge(): Single<Badge> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService()
|
||||
.getBoostBadge(Locale.getDefault())
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
|
||||
.map(Badges::fromServiceBadge)
|
||||
}
|
||||
|
||||
fun waitForOneTimeRedemption(
|
||||
price: FiatMoney,
|
||||
paymentIntentId: String,
|
||||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long,
|
||||
): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
val donationReceiptRecord = if (isBoost) {
|
||||
DonationReceiptRecord.createForBoost(price)
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(price)
|
||||
}
|
||||
|
||||
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
|
||||
|
||||
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
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(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
|
||||
return waitOnRedemption
|
||||
}
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
|
||||
/**
|
||||
* Manages bindings with payment APIs
|
||||
*
|
||||
* Steps for setting up payments for a subscription:
|
||||
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
|
||||
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
|
||||
* 1. Create a SetupIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 1. Confirm the SetupIntent via the Stripe API
|
||||
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
|
||||
*
|
||||
* For Boosts and Gifts:
|
||||
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
|
||||
* 1. Create a PaymentIntent via the Stripe API
|
||||
* 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 {
|
||||
|
||||
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())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
}
|
||||
|
||||
fun scheduleSyncForAccountRecordChange() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
scheduleSyncForAccountRecordChangeSync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSyncForAccountRecordChangeSync() {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
Log.d(TAG, "Requesting a token from google pay...")
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
Log.d(TAG, "Processing possible google pay result...")
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param price The amount to charce the local user
|
||||
* @param badgeRecipient Who will be getting the badge
|
||||
*/
|
||||
fun continuePayment(
|
||||
price: FiatMoney,
|
||||
badgeRecipient: RecipientId,
|
||||
badgeLevel: Long,
|
||||
): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel)
|
||||
.onErrorResumeNext {
|
||||
handleCreatePaymentIntentError(it, badgeRecipient)
|
||||
}
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
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.Success -> Single.just(result.paymentIntent)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
return stripeApi.createSetupIntent()
|
||||
.flatMap { result ->
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmPayment(
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
paymentIntent: StripeIntentAccessor,
|
||||
badgeRecipient: RecipientId
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
.onErrorResumeNext {
|
||||
Single.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
|
||||
}
|
||||
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}.doOnSuccess {
|
||||
Log.d(TAG, "Got payment intent from Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching setup intent from Signal service...")
|
||||
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
|
||||
.flatMap {
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createStripeSubscriptionPaymentMethod(it.subscriberId)
|
||||
}
|
||||
}
|
||||
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
Log.d(TAG, "Got setup intent from Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
// We need to get the status and payment id from the intent.
|
||||
|
||||
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
|
||||
return Single.fromCallable {
|
||||
when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
}
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
}
|
||||
}
|
||||
|
||||
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<StripeApi.PaymentSource> {
|
||||
Log.d(TAG, "Creating credit card payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromCardData(cardData).map {
|
||||
when (it) {
|
||||
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason)
|
||||
is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StripeRepository::class.java)
|
||||
|
||||
fun <T> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId): Single<T> {
|
||||
return if (throwable is DonationError) {
|
||||
Single.error(throwable)
|
||||
} else {
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
Single.error(DonationError.getPaymentSetupError(errorSource, throwable))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository(private val donationsService: DonationsService) {
|
||||
|
||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
return if (localSubscription != null) {
|
||||
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||
} else {
|
||||
Single.just(ActiveSubscription.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptions(): Single<List<Subscription>> = Single
|
||||
.fromCallable { donationsService.getSubscriptionLevels(Locale.getDefault()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
|
||||
.map { subscriptionLevels ->
|
||||
subscriptionLevels.levels.map { (code, level) ->
|
||||
Subscription(
|
||||
id = code,
|
||||
name = level.name,
|
||||
badge = Badges.fromServiceBadge(level.badge),
|
||||
prices = level.currencies.filter {
|
||||
PlatformCurrencyUtil
|
||||
.getAvailableCurrencyCodes()
|
||||
.contains(it.key)
|
||||
}.map { (currencyCode, price) ->
|
||||
FiatMoney(price, Currency.getInstance(currencyCode))
|
||||
}.toSet(),
|
||||
level = code.toInt()
|
||||
)
|
||||
}.sortedBy {
|
||||
it.level
|
||||
}
|
||||
}
|
||||
|
||||
fun syncAccountRecord(): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class BoostRepository(private val donationsService: DonationsService) {
|
||||
|
||||
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
|
||||
return Single.fromCallable { donationsService.boostAmounts }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
||||
.map { result ->
|
||||
result
|
||||
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
|
||||
.mapKeys { (code, _) -> Currency.getInstance(code) }
|
||||
.mapValues { (currency, prices) -> prices.map { Boost(FiatMoney(it, currency)) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun getBoostBadge(): Single<Badge> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService()
|
||||
.getBoostBadge(Locale.getDefault())
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
|
||||
.map(Badges::fromServiceBadge)
|
||||
}
|
||||
}
|
|
@ -40,7 +40,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
|||
summary = DSLSettingsText.from(currency.currencyCode),
|
||||
onClick = {
|
||||
viewModel.setSelectedCurrency(currency.currencyCode)
|
||||
donationPaymentComponent.donationPaymentRepository.scheduleSyncForAccountRecordChange()
|
||||
donationPaymentComponent.stripeRepository.scheduleSyncForAccountRecordChange()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
|
|
|
@ -6,7 +6,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject
|
|||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
|
||||
/**
|
||||
* Activity wrapper for donate to signal screen. An activity is needed because Google Pay uses the
|
||||
|
@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
|
|||
*/
|
||||
class DonateToSignalActivity : FragmentWrapperActivity(), DonationPaymentComponent {
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun getFragment(): Fragment {
|
||||
|
|
|
@ -41,8 +41,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ca
|
|||
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.stripe.StripeAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeActionResult
|
||||
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.DonationError
|
||||
|
@ -104,7 +102,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
donationPaymentComponent = requireListener()
|
||||
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.donationPaymentRepository)
|
||||
StripePaymentInProgressViewModel.Factory(donationPaymentComponent.stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -144,8 +142,8 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
}
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: StripeActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
|
||||
handleStripeActionResult(result)
|
||||
val result: DonationProcessorActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!!
|
||||
handleDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
|
||||
|
@ -208,7 +206,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
is DonateToSignalAction.CancelSubscription -> {
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
StripeAction.CANCEL_SUBSCRIPTION,
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION,
|
||||
action.gatewayRequest
|
||||
)
|
||||
)
|
||||
|
@ -216,7 +214,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
is DonateToSignalAction.UpdateSubscription -> {
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
StripeAction.UPDATE_SUBSCRIPTION,
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION,
|
||||
action.gatewayRequest
|
||||
)
|
||||
)
|
||||
|
@ -432,28 +430,28 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
|
||||
Log.d(TAG, "Received credit card information from fragment.")
|
||||
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
|
||||
}
|
||||
|
||||
private fun handleStripeActionResult(result: StripeActionResult) {
|
||||
private fun handleDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
when (result.status) {
|
||||
StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result)
|
||||
StripeActionResult.Status.FAILURE -> handleFailedStripeActionResult(result)
|
||||
DonationProcessorActionResult.Status.SUCCESS -> handleSuccessfulDonationProcessorActionResult(result)
|
||||
DonationProcessorActionResult.Status.FAILURE -> handleFailedDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
private fun handleSuccessfulStripeActionResult(result: StripeActionResult) {
|
||||
if (result.action == StripeAction.CANCEL_SUBSCRIPTION) {
|
||||
private fun handleSuccessfulDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFailedStripeActionResult(result: StripeActionResult) {
|
||||
if (result.action == StripeAction.CANCEL_SUBSCRIPTION) {
|
||||
private fun handleFailedDonationProcessorActionResult(result: DonationProcessorActionResult) {
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
|
||||
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
|
||||
|
@ -468,7 +466,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
|
||||
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
|
||||
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
|
||||
donationPaymentComponent.donationPaymentRepository.requestTokenFromGooglePay(
|
||||
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
|
||||
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
|
||||
label = gatewayResponse.request.label,
|
||||
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
|
||||
|
@ -487,7 +485,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
|
||||
onNext = { paymentResult ->
|
||||
viewModel.consumeGatewayRequestForGooglePay()?.let {
|
||||
donationPaymentComponent.donationPaymentRepository.onActivityResult(
|
||||
donationPaymentComponent.stripeRepository.onActivityResult(
|
||||
paymentResult.requestCode,
|
||||
paymentResult.resultCode,
|
||||
paymentResult.data,
|
||||
|
@ -549,7 +547,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
|||
override fun onSuccess(paymentData: PaymentData) {
|
||||
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
|
||||
stripePaymentViewModel.providePaymentData(paymentData)
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, request))
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, request))
|
||||
}
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
|
|
|
@ -12,9 +12,9 @@ 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.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
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.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.SubscriptionRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
@ -42,8 +42,8 @@ import java.util.Currency
|
|||
*/
|
||||
class DonateToSignalViewModel(
|
||||
startType: DonateToSignalType,
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
private val boostRepository: BoostRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
|
@ -63,7 +63,7 @@ class DonateToSignalViewModel(
|
|||
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
init {
|
||||
initializeOneTimeDonationState(boostRepository)
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
initializeMonthlyDonationState(subscriptionsRepository)
|
||||
|
||||
networkDisposable += InternetConnectionObserver
|
||||
|
@ -87,7 +87,7 @@ 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(boostRepository)
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -197,8 +197,8 @@ class DonateToSignalViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun initializeOneTimeDonationState(boostRepository: BoostRepository) {
|
||||
oneTimeDonationDisposables += boostRepository.getBoostBadge().subscribeBy(
|
||||
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
|
||||
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
|
||||
onSuccess = { badge ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
|
||||
},
|
||||
|
@ -207,7 +207,7 @@ class DonateToSignalViewModel(
|
|||
}
|
||||
)
|
||||
|
||||
val boosts: Observable<Map<Currency, List<Boost>>> = boostRepository.getBoosts().toObservable()
|
||||
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeDonationRepository.getBoosts().toObservable()
|
||||
val oneTimeCurrency: Observable<Currency> = SignalStore.donationsValues().observableOneTimeCurrency
|
||||
|
||||
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
|
||||
|
@ -243,7 +243,7 @@ class DonateToSignalViewModel(
|
|||
)
|
||||
}
|
||||
|
||||
private fun initializeMonthlyDonationState(subscriptionsRepository: SubscriptionsRepository) {
|
||||
private fun initializeMonthlyDonationState(subscriptionsRepository: MonthlyDonationRepository) {
|
||||
monitorLevelUpdateProcessing()
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions()
|
||||
|
@ -362,11 +362,11 @@ class DonateToSignalViewModel(
|
|||
|
||||
class Factory(
|
||||
private val startType: DonateToSignalType,
|
||||
private val subscriptionsRepository: SubscriptionsRepository = SubscriptionsRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val boostRepository: BoostRepository = BoostRepository(ApplicationDependencies.getDonationsService())
|
||||
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, boostRepository)) as T
|
||||
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeDonationRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
enum class StripeAction : Parcelable {
|
||||
enum class DonationProcessorAction : Parcelable {
|
||||
PROCESS_NEW_DONATION,
|
||||
UPDATE_SUBSCRIPTION,
|
||||
CANCEL_SUBSCRIPTION
|
|
@ -1,12 +1,12 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
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
|
||||
|
||||
@Parcelize
|
||||
class StripeActionResult(
|
||||
val action: StripeAction,
|
||||
class DonationProcessorActionResult(
|
||||
val action: DonationProcessorAction,
|
||||
val request: GatewayRequest,
|
||||
val status: Status
|
||||
) : Parcelable {
|
|
@ -1,6 +1,6 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
enum class StripeStage {
|
||||
enum class DonationProcessorStage {
|
||||
INIT,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING,
|
|
@ -34,7 +34,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
|||
private val args: GatewaySelectorBottomSheetArgs by navArgs()
|
||||
|
||||
private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = {
|
||||
GatewaySelectorViewModel.Factory(args, requireListener<DonationPaymentComponent>().donationPaymentRepository)
|
||||
GatewaySelectorViewModel.Factory(args, requireListener<DonationPaymentComponent>().stripeRepository)
|
||||
})
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
|
@ -64,7 +64,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
|||
DonateToSignalType.ONE_TIME -> presentOneTimeText()
|
||||
}
|
||||
|
||||
space(68.dp)
|
||||
space(66.dp)
|
||||
|
||||
if (state.isGooglePayAvailable) {
|
||||
customPref(
|
||||
|
|
|
@ -5,12 +5,12 @@ import androidx.lifecycle.ViewModelProvider
|
|||
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.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
class GatewaySelectorViewModel(
|
||||
args: GatewaySelectorBottomSheetArgs,
|
||||
private val repository: DonationPaymentRepository
|
||||
private val repository: StripeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = RxStore(GatewaySelectorState(args.request.badge))
|
||||
|
@ -40,7 +40,7 @@ class GatewaySelectorViewModel(
|
|||
|
||||
class Factory(
|
||||
private val args: GatewaySelectorBottomSheetArgs,
|
||||
private val repository: DonationPaymentRepository
|
||||
private val repository: StripeRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(GatewaySelectorViewModel(args, repository)) as T
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
|
@ -22,7 +22,9 @@ import org.signal.donations.StripeIntentAccessor
|
|||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Stripe3DSDialogFragment
|
||||
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.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
@ -43,7 +45,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
|||
private val viewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().donationPaymentRepository)
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -58,13 +60,13 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
|||
if (savedInstanceState == null) {
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
StripeAction.PROCESS_NEW_DONATION -> {
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION -> {
|
||||
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
|
||||
}
|
||||
StripeAction.UPDATE_SUBSCRIPTION -> {
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
viewModel.updateSubscription(args.request)
|
||||
}
|
||||
StripeAction.CANCEL_SUBSCRIPTION -> {
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
}
|
||||
|
@ -76,39 +78,39 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
|||
}
|
||||
}
|
||||
|
||||
private fun presentUiState(stage: StripeStage) {
|
||||
private fun presentUiState(stage: DonationProcessorStage) {
|
||||
when (stage) {
|
||||
StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
StripeStage.FAILED -> {
|
||||
DonationProcessorStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
DonationProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
DonationProcessorStage.FAILED -> {
|
||||
viewModel.onEndAction()
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
bundleOf(
|
||||
REQUEST_KEY to StripeActionResult(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
status = StripeActionResult.Status.FAILURE
|
||||
status = DonationProcessorActionResult.Status.FAILURE
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
StripeStage.COMPLETE -> {
|
||||
DonationProcessorStage.COMPLETE -> {
|
||||
viewModel.onEndAction()
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
bundleOf(
|
||||
REQUEST_KEY to StripeActionResult(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
status = StripeActionResult.Status.SUCCESS
|
||||
status = DonationProcessorActionResult.Status.SUCCESS
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
|
||||
DonationProcessorStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,8 +14,11 @@ import org.signal.core.util.logging.Log
|
|||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
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.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
|
||||
|
@ -28,15 +31,17 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
|||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class StripePaymentInProgressViewModel(
|
||||
private val donationPaymentRepository: DonationPaymentRepository
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StripePaymentInProgressViewModel::class.java)
|
||||
}
|
||||
|
||||
private val store = RxStore(StripeStage.INIT)
|
||||
val state: Flowable<StripeStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
private val store = RxStore(DonationProcessorStage.INIT)
|
||||
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private var paymentData: PaymentData? = null
|
||||
|
@ -59,7 +64,7 @@ class StripePaymentInProgressViewModel(
|
|||
Preconditions.checkState(store.state.isTerminal)
|
||||
|
||||
Log.d(TAG, "Ending current state. Clearing state and setting stage to INIT", true)
|
||||
store.update { StripeStage.INIT }
|
||||
store.update { DonationProcessorStage.INIT }
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
|
@ -87,7 +92,7 @@ class StripePaymentInProgressViewModel(
|
|||
paymentData == null && cardData == null -> error("No payment provider available.")
|
||||
paymentData != null && cardData != null -> error("Too many providers available")
|
||||
paymentData != null -> Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData))
|
||||
cardData != null -> donationPaymentRepository.createCreditCardPaymentSource(errorSource, cardData)
|
||||
cardData != null -> stripeRepository.createCreditCardPaymentSource(errorSource, cardData)
|
||||
else -> error("This should never happen.")
|
||||
}.doAfterTerminate { clearPaymentInformation() }
|
||||
}
|
||||
|
@ -114,28 +119,28 @@ class StripePaymentInProgressViewModel(
|
|||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single<StripeApi.PaymentSource>, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId()
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) }
|
||||
val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
|
||||
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { stripeRepository.createAndConfirmSetupIntent(it) }
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
|
||||
val setup: Completable = ensureSubscriberId
|
||||
.andThen(cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(createAndConfirmSetupIntent)
|
||||
.flatMap { secure3DSAction ->
|
||||
nextActionHandler(secure3DSAction)
|
||||
.flatMap { secure3DSResult -> donationPaymentRepository.getStatusAndPaymentMethodId(secure3DSResult) }
|
||||
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
|
||||
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
|
||||
}
|
||||
.flatMapCompletable { donationPaymentRepository.setDefaultPaymentMethod(it) }
|
||||
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
|
||||
|
||||
disposables += setup.andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
||||
store.update { StripeStage.FAILED }
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
|
@ -146,25 +151,11 @@ class StripePaymentInProgressViewModel(
|
|||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished subscription payment pipeline...", true)
|
||||
store.update { StripeStage.COMPLETE }
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun cancelActiveSubscriptionIfNecessary(): Completable {
|
||||
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
|
||||
if (it) {
|
||||
Log.d(TAG, "Cancelling active subscription...", true)
|
||||
donationPaymentRepository.cancelActiveSubscription().doOnComplete {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedOneTime(
|
||||
request: GatewayRequest,
|
||||
paymentSourceProvider: Single<StripeApi.PaymentSource>,
|
||||
|
@ -176,18 +167,18 @@ class StripePaymentInProgressViewModel(
|
|||
val recipient = Recipient.self().id
|
||||
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
|
||||
|
||||
val continuePayment: Single<StripeIntentAccessor> = donationPaymentRepository.continuePayment(amount, recipient, level)
|
||||
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(amount, recipient, level)
|
||||
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
.flatMap { nextActionHandler(it) }
|
||||
.flatMap { donationPaymentRepository.getStatusAndPaymentMethodId(it) }
|
||||
.flatMapCompletable { donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level) }
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
|
||||
.flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption(amount, paymentIntent.intentId, recipient, null, level) }
|
||||
}.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { StripeStage.FAILED }
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
|
@ -198,7 +189,7 @@ class StripePaymentInProgressViewModel(
|
|||
},
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed one-time payment pipeline...", true)
|
||||
store.update { StripeStage.COMPLETE }
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -206,18 +197,18 @@ class StripePaymentInProgressViewModel(
|
|||
fun cancelSubscription() {
|
||||
Log.d(TAG, "Beginning cancellation...", true)
|
||||
|
||||
store.update { StripeStage.CANCELLING }
|
||||
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
||||
store.update { DonationProcessorStage.CANCELLING }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
|
||||
onComplete = {
|
||||
Log.d(TAG, "Cancellation succeeded", true)
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
donationPaymentRepository.scheduleSyncForAccountRecordChange()
|
||||
store.update { StripeStage.COMPLETE }
|
||||
stripeRepository.scheduleSyncForAccountRecordChange()
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Cancellation failed", throwable, true)
|
||||
store.update { StripeStage.FAILED }
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -225,12 +216,12 @@ class StripePaymentInProgressViewModel(
|
|||
fun updateSubscription(request: GatewayRequest) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||
disposables += cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
store.update { StripeStage.COMPLETE }
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
|
@ -241,16 +232,18 @@ class StripePaymentInProgressViewModel(
|
|||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
store.update { StripeStage.FAILED }
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val donationPaymentRepository: DonationPaymentRepository
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(StripePaymentInProgressViewModel(donationPaymentRepository)) as T
|
||||
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
|||
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.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
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
|
||||
|
@ -58,7 +58,7 @@ class ManageDonationsFragment :
|
|||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
ManageDonationsViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
|||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
|||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
|
@ -122,7 +122,7 @@ class ManageDonationsViewModel(
|
|||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
|
||||
|
|
|
@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.R
|
|||
import org.thoughtcrime.securesms.components.HidingLinearLayout
|
||||
import org.thoughtcrime.securesms.components.reminder.ReminderView
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
@ -113,6 +113,6 @@ open class ConversationActivity : PassphraseRequiredActivity(), ConversationPare
|
|||
return fragment.reminderView
|
||||
}
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
private final String paymentIntentId;
|
||||
private final long badgeLevel;
|
||||
|
||||
private static BoostReceiptRequestResponseJob createJob(StripeIntentAccessor paymentIntent, DonationErrorSource donationErrorSource, long badgeLevel) {
|
||||
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel) {
|
||||
return new BoostReceiptRequestResponseJob(
|
||||
new Parameters
|
||||
.Builder()
|
||||
|
@ -65,14 +65,14 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
null,
|
||||
paymentIntent.getIntentId(),
|
||||
paymentIntentId,
|
||||
donationErrorSource,
|
||||
badgeLevel
|
||||
);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForBoost(@NonNull StripeIntentAccessor paymentIntent) {
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId) {
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
|
||||
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
||||
|
@ -84,12 +84,12 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
.then(multiDeviceProfileContentUpdateJob);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForGift(@NonNull StripeIntentAccessor paymentIntent,
|
||||
public static JobManager.Chain createJobChainForGift(@NonNull String paymentIntentId,
|
||||
@NonNull RecipientId recipientId,
|
||||
@Nullable String additionalMessage,
|
||||
long badgeLevel)
|
||||
{
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.GIFT, badgeLevel);
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel);
|
||||
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
|
||||
|
||||
|
||||
|
|
|
@ -8,9 +8,11 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:insetTop="2dp"
|
||||
android:insetBottom="2dp"
|
||||
app:iconGravity="textStart"
|
||||
app:iconSize="32dp"
|
||||
app:iconTint="@null"
|
||||
tools:icon="@drawable/credit_card"
|
||||
app:iconSize="32dp"
|
||||
tools:text="Primary button"
|
||||
tools:viewBindingIgnore="true" />
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
<argument
|
||||
android:name="action"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeAction"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction"
|
||||
app:nullable="false" />
|
||||
|
||||
<argument
|
||||
|
@ -127,7 +127,7 @@
|
|||
|
||||
<dialog
|
||||
android:id="@+id/stripe3dsDialogFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.Stripe3DSDialogFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment"
|
||||
android:label="stripe_3ds_dialog_fragment"
|
||||
tools:layout="@layout/stripe_3ds_dialog_fragment">
|
||||
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44sp"
|
||||
android:layout_marginTop="2sp"
|
||||
android:layout_marginBottom="2sp"
|
||||
android:background="@drawable/donate_with_google_pay_rounded_background"
|
||||
android:padding="2sp"
|
||||
android:contentDescription="@string/donate_with_googlepay_button_content_description">
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/donate_with_googlepay_button_content_description"
|
||||
android:focusable="true"
|
||||
android:padding="2sp">
|
||||
|
||||
<LinearLayout
|
||||
android:duplicateParentState="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:weightSum="2"
|
||||
android:duplicateParentState="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:weightSum="2">
|
||||
|
||||
<ImageView
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_weight="1"
|
||||
android:duplicateParentState="true"
|
||||
android:src="@drawable/donate_with_googlepay_button_content"/>
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/donate_with_googlepay_button_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="fitXY"
|
||||
android:duplicateParentState="true"
|
||||
android:src="@drawable/googlepay_button_overlay"/>
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/googlepay_button_overlay" />
|
||||
</RelativeLayout>
|
|
@ -9,8 +9,8 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
|||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
|
@ -43,7 +43,8 @@ public class DonationsService {
|
|||
String signalAgent,
|
||||
GroupsV2Operations groupsV2Operations,
|
||||
boolean automaticNetworkRetry
|
||||
) {
|
||||
)
|
||||
{
|
||||
this(new PushServiceSocket(configuration, credentialsProvider, signalAgent, groupsV2Operations.getProfileOperations(), automaticNetworkRetry));
|
||||
}
|
||||
|
||||
|
@ -71,12 +72,12 @@ public class DonationsService {
|
|||
/**
|
||||
* Submits price information to the server to generate a payment intent via the payment gateway.
|
||||
*
|
||||
* @param amount Price, in the minimum currency unit (e.g. cents or yen)
|
||||
* @param currencyCode The currency code for the amount
|
||||
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
|
||||
* @param amount Price, in the minimum currency unit (e.g. cents or yen)
|
||||
* @param currencyCode The currency code for the amount
|
||||
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
|
||||
*/
|
||||
public ServiceResponse<SubscriptionClientSecret> createDonationIntentWithAmount(String amount, String currencyCode, long level) {
|
||||
return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.createBoostPaymentMethod(currencyCode, Long.parseLong(amount), level), 200));
|
||||
public ServiceResponse<StripeClientSecret> createDonationIntentWithAmount(String amount, String currencyCode, long level) {
|
||||
return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.createStripeOneTimePaymentIntent(currencyCode, Long.parseLong(amount), level), 200));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -165,9 +166,10 @@ public class DonationsService {
|
|||
String currencyCode,
|
||||
String idempotencyKey,
|
||||
Object mutex
|
||||
) {
|
||||
)
|
||||
{
|
||||
return wrapInServiceResponse(() -> {
|
||||
synchronized(mutex) {
|
||||
synchronized (mutex) {
|
||||
pushServiceSocket.updateSubscriptionLevel(subscriberId.serialize(), level, currencyCode, idempotencyKey);
|
||||
}
|
||||
return new Pair<>(EmptyResponse.INSTANCE, 200);
|
||||
|
@ -188,11 +190,11 @@ public class DonationsService {
|
|||
* Creates a subscriber record on the signal server and stripe. Can be called idempotently as-is. After receiving 200 from this endpoint,
|
||||
* clients should save subscriberId locally and to storage service for the account. If you get a 403 from this endpoint and you did not
|
||||
* use an account authenticated connection, then the subscriberId has been corrupted in some way.
|
||||
*
|
||||
* <p>
|
||||
* Clients MUST periodically hit this endpoint to update the access time on the subscription record. Recommend trying to call it approximately
|
||||
* every 3 days. Not accessing this endpoint for an extended period of time will result in the subscription being canceled.
|
||||
*
|
||||
* @param subscriberId The subscriber ID for the user polling their subscription
|
||||
* @param subscriberId The subscriber ID for the user polling their subscription
|
||||
*/
|
||||
public ServiceResponse<EmptyResponse> putSubscription(SubscriberId subscriberId) {
|
||||
return wrapInServiceResponse(() -> {
|
||||
|
@ -204,7 +206,7 @@ public class DonationsService {
|
|||
/**
|
||||
* Cancels any current subscription at the end of the current subscription period.
|
||||
*
|
||||
* @param subscriberId The subscriber ID for the user cancelling their subscription
|
||||
* @param subscriberId The subscriber ID for the user cancelling their subscription
|
||||
*/
|
||||
public ServiceResponse<EmptyResponse> cancelSubscription(SubscriberId subscriberId) {
|
||||
return wrapInServiceResponse(() -> {
|
||||
|
@ -213,7 +215,7 @@ public class DonationsService {
|
|||
});
|
||||
}
|
||||
|
||||
public ServiceResponse<EmptyResponse> setDefaultPaymentMethodId(SubscriberId subscriberId, String paymentMethodId) {
|
||||
public ServiceResponse<EmptyResponse> setDefaultStripePaymentMethod(SubscriberId subscriberId, String paymentMethodId) {
|
||||
return wrapInServiceResponse(() -> {
|
||||
pushServiceSocket.setDefaultSubscriptionPaymentMethod(subscriberId.serialize(), paymentMethodId);
|
||||
return new Pair<>(EmptyResponse.INSTANCE, 200);
|
||||
|
@ -226,9 +228,9 @@ public class DonationsService {
|
|||
* @return Client secret for a SetupIntent. It should not be used with the PaymentIntent stripe APIs
|
||||
* but instead with the SetupIntent stripe APIs.
|
||||
*/
|
||||
public ServiceResponse<SubscriptionClientSecret> createSubscriptionPaymentMethod(SubscriberId subscriberId) {
|
||||
public ServiceResponse<StripeClientSecret> createStripeSubscriptionPaymentMethod(SubscriberId subscriberId) {
|
||||
return wrapInServiceResponse(() -> {
|
||||
SubscriptionClientSecret clientSecret = pushServiceSocket.createSubscriptionPaymentMethod(subscriberId.serialize());
|
||||
StripeClientSecret clientSecret = pushServiceSocket.createSubscriptionPaymentMethod(subscriberId.serialize());
|
||||
return new Pair<>(clientSecret, 200);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,13 +3,13 @@ package org.whispersystems.signalservice.api.subscriptions;
|
|||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public final class SubscriptionClientSecret {
|
||||
public final class StripeClientSecret {
|
||||
|
||||
private final String id;
|
||||
private final String clientSecret;
|
||||
|
||||
@JsonCreator
|
||||
public SubscriptionClientSecret(@JsonProperty("clientSecret") String clientSecret) {
|
||||
public StripeClientSecret(@JsonProperty("clientSecret") String clientSecret) {
|
||||
this.id = clientSecret.replaceFirst("_secret.*", "");
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
|
@ -86,7 +86,7 @@ import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedExc
|
|||
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
|
||||
import org.whispersystems.signalservice.api.storage.StorageAuthResponse;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret;
|
||||
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
|
||||
|
@ -1017,10 +1017,10 @@ public class PushServiceSocket {
|
|||
makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload);
|
||||
}
|
||||
|
||||
public SubscriptionClientSecret createBoostPaymentMethod(String currencyCode, long amount, long level) throws IOException {
|
||||
String payload = JsonUtil.toJson(new DonationIntentPayload(amount, currencyCode, level));
|
||||
public StripeClientSecret createStripeOneTimePaymentIntent(String currencyCode, long amount, long level) throws IOException {
|
||||
String payload = JsonUtil.toJson(new StripeOneTimePaymentIntentPayload(amount, currencyCode, level));
|
||||
String result = makeServiceRequestWithoutAuthentication(CREATE_BOOST_PAYMENT_INTENT, "POST", payload);
|
||||
return JsonUtil.fromJsonResponse(result, SubscriptionClientSecret.class);
|
||||
return JsonUtil.fromJsonResponse(result, StripeClientSecret.class);
|
||||
}
|
||||
|
||||
public Map<String, List<BigDecimal>> getBoostAmounts() throws IOException {
|
||||
|
@ -1082,9 +1082,9 @@ public class PushServiceSocket {
|
|||
makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "DELETE", null);
|
||||
}
|
||||
|
||||
public SubscriptionClientSecret createSubscriptionPaymentMethod(String subscriberId) throws IOException {
|
||||
public StripeClientSecret createSubscriptionPaymentMethod(String subscriberId) throws IOException {
|
||||
String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", "");
|
||||
return JsonUtil.fromJson(response, SubscriptionClientSecret.class);
|
||||
return JsonUtil.fromJson(response, StripeClientSecret.class);
|
||||
}
|
||||
|
||||
public void setDefaultSubscriptionPaymentMethod(String subscriberId, String paymentMethodId) throws IOException {
|
||||
|
|
|
@ -2,7 +2,7 @@ package org.whispersystems.signalservice.internal.push;
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
class DonationIntentPayload {
|
||||
class StripeOneTimePaymentIntentPayload {
|
||||
@JsonProperty
|
||||
private long amount;
|
||||
|
||||
|
@ -12,7 +12,7 @@ class DonationIntentPayload {
|
|||
@JsonProperty
|
||||
private long level;
|
||||
|
||||
public DonationIntentPayload(long amount, String currency, long level) {
|
||||
public StripeOneTimePaymentIntentPayload(long amount, String currency, long level) {
|
||||
this.amount = amount;
|
||||
this.currency = currency;
|
||||
this.level = level;
|
Loading…
Add table
Reference in a new issue