Implement new APIs for Boost badging.

This commit is contained in:
Alex Hart
2021-10-28 09:11:45 -03:00
committed by Greyson Parrelli
parent 48a81da883
commit 186bd9db48
19 changed files with 457 additions and 137 deletions

View File

@@ -39,7 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
private val boostViewModel: BoostViewModel by viewModels(
factoryProducer = {
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
BoostViewModel.Factory(BoostRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
}
)

View File

@@ -11,7 +11,7 @@ import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
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.subscription.LevelUpdateOperation
@@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
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.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -67,7 +70,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent)
}
}
}
@@ -81,14 +84,9 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
fun cancelActiveSubscription(): Completable {
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
return ApplicationDependencies.getDonationsService()
.cancelSubscription(localSubscriber.subscriberId)
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
}
fun ensureSubscriberId(): Completable {
@@ -96,14 +94,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
return ApplicationDependencies
.getDonationsService()
.putSubscription(subscriberId)
.flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
.doOnComplete {
SignalStore
.donationsValues()
@@ -111,6 +102,36 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
return Completable.create {
stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).blockingSubscribe()
val jobIds = BoostReceiptRequestResponseJob.enqueueChain(paymentIntent)
val countDownLatch = CountDownLatch(2)
ApplicationDependencies.getJobManager().addListener(jobIds.first()) { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
ApplicationDependencies.getJobManager().addListener(jobIds.second()) { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onComplete()
} else {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
} catch (e: InterruptedException) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
@@ -121,40 +142,29 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize()
).flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
response.executionError.isPresent -> Completable.error(response.executionError.get())
else -> Completable.error(AssertionError("should never happen"))
}
}.andThen {
).flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().andThen {
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
it.onComplete()
}.andThen {
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
val countDownLatch = CountDownLatch(2)
val firstJobListener = JobTracker.JobListener { _, jobState ->
ApplicationDependencies.getJobManager().addListener(jobIds.first()) { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
val secondJobListener = JobTracker.JobListener { _, jobState ->
ApplicationDependencies.getJobManager().addListener(jobIds.second()) { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
try {
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
} else {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onComplete()
} else {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
} catch (e: InterruptedException) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
@@ -182,29 +192,17 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map {
StripeApi.PaymentIntent(it.id, it.clientSecret)
}
}
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
}.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.SetupIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
}
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
.flatMap { ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId) }
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map { StripeApi.SetupIntent(it.id, it.clientSecret) }
}
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
@@ -212,13 +210,6 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
}.flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.executionError.isPresent -> Completable.error(response.executionError.get())
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
else -> Completable.error(AssertionError("Should never get here"))
}
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
}
}

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
/**
@@ -18,14 +19,8 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
when {
it.status == 200 -> Single.just(it.result.get())
it.applicationError.isPresent -> Single.error(it.applicationError.get())
it.executionError.isPresent -> Single.error(it.executionError.get())
else -> throw AssertionError()
}
}
donationsService.getSubscription(localSubscription.subscriberId)
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
} else {
Single.just(ActiveSubscription(null))
}

View File

@@ -28,7 +28,6 @@ import java.util.regex.Pattern
* can unlock a corresponding badge for a time determined by the server.
*/
data class Boost(
val badge: Badge,
val price: FiatMoney
) {
@@ -93,7 +92,7 @@ data class Boost(
button.text = FiatMoneyUtil.format(
context.resources,
boost.price,
FiatMoneyUtil.formatOptions()
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
)
button.setOnClickListener {
model.onBoostClick(boost)

View File

@@ -1,47 +1,29 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.net.Uri
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
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
class BoostRepository {
class BoostRepository(private val donationsService: DonationsService) {
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
val boosts = testBoosts(currency)
return Single.just(
Pair(
boosts,
boosts[2]
)
)
fun getBoosts(currency: Currency): Single<List<Boost>> {
return donationsService.boostAmounts
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
.map { result ->
val boosts = result[currency.currencyCode] ?: throw Exception("Unsupported currency! ${currency.currencyCode}")
boosts.map { Boost(FiatMoney(it, currency)) }
}
}
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
// Get boost badge from server
// throw NotImplementedError()
testBadge
}
companion object {
private val testBadge = Badge(
id = "TEST",
category = Badge.Category.Testing,
name = "Test Badge",
description = "Test Badge",
imageUrl = Uri.EMPTY,
imageDensity = "xxxhdpi",
expirationTimestamp = 0L,
visible = false,
)
private fun testBoosts(currency: Currency) = listOf(
3L, 5L, 10L, 20L, 50L, 100L
).map {
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
}
fun getBoostBadge(): Single<Badge> {
return donationsService.boostBadge
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
.map(Badges::fromServiceBadge)
}
}

View File

@@ -45,7 +45,10 @@ class BoostViewModel(
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
val boostBadge = boostRepository.getBoostBadge()
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { info ->
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) {
boostList, badge ->
BoostInfo(boostList, boostList[2], badge)
}.subscribe { info ->
store.update {
it.copy(
boosts = info.boosts,
@@ -79,25 +82,22 @@ class BoostViewModel(
resultCode: Int,
data: Intent?
) {
val boost = boostToPurchase
boostToPurchase = null
donationPaymentRepository.onActivityResult(
requestCode,
resultCode,
data,
this.fetchTokenRequestCode,
requestCode, resultCode, data, this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val boost = boostToPurchase
boostToPurchase = null
if (boost != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
},
onComplete = {
// TODO [alex] Now we need to do the whole query for a token, submit token rigamarole
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
}
@@ -127,10 +127,8 @@ class BoostViewModel(
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
// TODO [alex] -- Custom boost badge details... how do we determine this?
boostToPurchase = if (snapshot.isCustomAmountFocused) {
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
Boost(snapshot.customAmount)
} else {
snapshot.selectedBoost
}

View File

@@ -11,14 +11,18 @@ import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
class SetCurrencyViewModel(private val isBoost: Boolean) : ViewModel() {
private val store = Store(SetCurrencyState())
val state: LiveData<SetCurrencyState> = store.stateLiveData
init {
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
val defaultCurrency = if (isBoost) {
SignalStore.donationsValues().getBoostCurrency()
} else {
SignalStore.donationsValues().getSubscriptionCurrency()
}
store.update { state ->
val platformCurrencies = Currency.getAvailableCurrencies()

View File

@@ -107,7 +107,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
if (controlState == ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
} else {
} else if (controlChecked) {
badgeRepository.setFeaturedBadge(args.badge).subscribe()
}