Refactor a large portion of the payments code to prep it for PayPal support.

main
Alex Hart 2022-11-10 12:57:21 -04:00 zatwierdzone przez Greyson Parrelli
rodzic c563ef27da
commit 9d71c4df81
39 zmienionych plików z 839 dodań i 779 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -40,7 +40,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
summary = DSLSettingsText.from(currency.currencyCode),
onClick = {
viewModel.setSelectedCurrency(currency.currencyCode)
donationPaymentComponent.donationPaymentRepository.scheduleSyncForAccountRecordChange()
donationPaymentComponent.stripeRepository.scheduleSyncForAccountRecordChange()
dismissAllowingStateLoss()
}
)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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" />

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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