Implement new APIs for Boost badging.
This commit is contained in:
parent
48a81da883
commit
186bd9db48
19 changed files with 457 additions and 137 deletions
|
@ -23,7 +23,7 @@ class BadgeRepository(context: Context) {
|
||||||
|
|
||||||
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
||||||
val badges = Recipient.self().badges
|
val badges = Recipient.self().badges
|
||||||
val reOrderedBadges = listOf(featuredBadge) + (badges - featuredBadge)
|
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
|
||||||
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
||||||
|
|
||||||
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)
|
||||||
|
|
|
@ -39,7 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||||
|
|
||||||
private val boostViewModel: BoostViewModel by viewModels(
|
private val boostViewModel: BoostViewModel by viewModels(
|
||||||
factoryProducer = {
|
factoryProducer = {
|
||||||
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
|
BoostViewModel.Factory(BoostRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import org.signal.donations.GooglePayApi
|
||||||
import org.signal.donations.GooglePayPaymentSource
|
import org.signal.donations.GooglePayPaymentSource
|
||||||
import org.signal.donations.StripeApi
|
import org.signal.donations.StripeApi
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
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.jobs.SubscriptionReceiptRequestResponseJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||||
|
@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.subscription.Subscriber
|
||||||
import org.thoughtcrime.securesms.util.Environment
|
import org.thoughtcrime.securesms.util.Environment
|
||||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
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.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
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.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
|
||||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
|
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.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 {
|
fun cancelActiveSubscription(): Completable {
|
||||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||||
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
|
return ApplicationDependencies.getDonationsService()
|
||||||
when {
|
.cancelSubscription(localSubscriber.subscriberId)
|
||||||
it.status == 200 -> Completable.complete()
|
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||||
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
|
|
||||||
it.executionError.isPresent -> Completable.error(it.executionError.get())
|
|
||||||
else -> Completable.error(AssertionError("Something bad happened"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ensureSubscriberId(): Completable {
|
fun ensureSubscriberId(): Completable {
|
||||||
|
@ -96,14 +94,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
return ApplicationDependencies
|
return ApplicationDependencies
|
||||||
.getDonationsService()
|
.getDonationsService()
|
||||||
.putSubscription(subscriberId)
|
.putSubscription(subscriberId)
|
||||||
.flatMapCompletable {
|
.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.doOnComplete {
|
.doOnComplete {
|
||||||
SignalStore
|
SignalStore
|
||||||
.donationsValues()
|
.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 {
|
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||||
.flatMapCompletable { levelUpdateOperation ->
|
.flatMapCompletable { levelUpdateOperation ->
|
||||||
|
@ -121,40 +142,29 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
subscriptionLevel,
|
subscriptionLevel,
|
||||||
subscriber.currencyCode,
|
subscriber.currencyCode,
|
||||||
levelUpdateOperation.idempotencyKey.serialize()
|
levelUpdateOperation.idempotencyKey.serialize()
|
||||||
).flatMapCompletable { response ->
|
).flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().andThen {
|
||||||
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 {
|
|
||||||
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
|
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
|
||||||
it.onComplete()
|
it.onComplete()
|
||||||
}.andThen {
|
}.andThen {
|
||||||
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
|
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
|
||||||
val countDownLatch = CountDownLatch(2)
|
val countDownLatch = CountDownLatch(2)
|
||||||
|
|
||||||
val firstJobListener = JobTracker.JobListener { _, jobState ->
|
ApplicationDependencies.getJobManager().addListener(jobIds.first()) { _, jobState ->
|
||||||
if (jobState.isComplete) {
|
if (jobState.isComplete) {
|
||||||
countDownLatch.countDown()
|
countDownLatch.countDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ApplicationDependencies.getJobManager().addListener(jobIds.second()) { _, jobState ->
|
||||||
val secondJobListener = JobTracker.JobListener { _, jobState ->
|
|
||||||
if (jobState.isComplete) {
|
if (jobState.isComplete) {
|
||||||
countDownLatch.countDown()
|
countDownLatch.countDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
|
|
||||||
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
|
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
|
||||||
} else {
|
|
||||||
it.onComplete()
|
it.onComplete()
|
||||||
|
} else {
|
||||||
|
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||||
}
|
}
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||||
|
@ -182,29 +192,17 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
return ApplicationDependencies
|
return ApplicationDependencies
|
||||||
.getDonationsService()
|
.getDonationsService()
|
||||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||||
.flatMap { response ->
|
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||||
when {
|
.map {
|
||||||
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
|
StripeApi.PaymentIntent(it.id, it.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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
|
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
|
||||||
return Single.fromCallable {
|
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
|
||||||
SignalStore.donationsValues().requireSubscriber()
|
.flatMap { ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId) }
|
||||||
}.flatMap {
|
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||||
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
|
.map { StripeApi.SetupIntent(it.id, it.clientSecret) }
|
||||||
}.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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||||
|
@ -212,13 +210,6 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
SignalStore.donationsValues().requireSubscriber()
|
SignalStore.donationsValues().requireSubscriber()
|
||||||
}.flatMap {
|
}.flatMap {
|
||||||
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
|
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
|
||||||
}.flatMapCompletable { response ->
|
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement()
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.subscription.Subscription
|
import org.thoughtcrime.securesms.subscription.Subscription
|
||||||
import org.whispersystems.signalservice.api.services.DonationsService
|
import org.whispersystems.signalservice.api.services.DonationsService
|
||||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||||
|
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -18,14 +19,8 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
|
||||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||||
return if (localSubscription != null) {
|
return if (localSubscription != null) {
|
||||||
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
|
donationsService.getSubscription(localSubscription.subscriberId)
|
||||||
when {
|
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Single.just(ActiveSubscription(null))
|
Single.just(ActiveSubscription(null))
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,6 @@ import java.util.regex.Pattern
|
||||||
* can unlock a corresponding badge for a time determined by the server.
|
* can unlock a corresponding badge for a time determined by the server.
|
||||||
*/
|
*/
|
||||||
data class Boost(
|
data class Boost(
|
||||||
val badge: Badge,
|
|
||||||
val price: FiatMoney
|
val price: FiatMoney
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -93,7 +92,7 @@ data class Boost(
|
||||||
button.text = FiatMoneyUtil.format(
|
button.text = FiatMoneyUtil.format(
|
||||||
context.resources,
|
context.resources,
|
||||||
boost.price,
|
boost.price,
|
||||||
FiatMoneyUtil.formatOptions()
|
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
|
||||||
)
|
)
|
||||||
button.setOnClickListener {
|
button.setOnClickListener {
|
||||||
model.onBoostClick(boost)
|
model.onBoostClick(boost)
|
||||||
|
|
|
@ -1,47 +1,29 @@
|
||||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
|
import org.thoughtcrime.securesms.badges.Badges
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
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.math.BigDecimal
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
|
|
||||||
class BoostRepository {
|
class BoostRepository(private val donationsService: DonationsService) {
|
||||||
|
|
||||||
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
|
fun getBoosts(currency: Currency): Single<List<Boost>> {
|
||||||
val boosts = testBoosts(currency)
|
return donationsService.boostAmounts
|
||||||
|
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
||||||
return Single.just(
|
.map { result ->
|
||||||
Pair(
|
val boosts = result[currency.currencyCode] ?: throw Exception("Unsupported currency! ${currency.currencyCode}")
|
||||||
boosts,
|
boosts.map { Boost(FiatMoney(it, currency)) }
|
||||||
boosts[2]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,10 @@ class BoostViewModel(
|
||||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||||
val boostBadge = boostRepository.getBoostBadge()
|
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 {
|
store.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
boosts = info.boosts,
|
boosts = info.boosts,
|
||||||
|
@ -79,25 +82,22 @@ class BoostViewModel(
|
||||||
resultCode: Int,
|
resultCode: Int,
|
||||||
data: Intent?
|
data: Intent?
|
||||||
) {
|
) {
|
||||||
donationPaymentRepository.onActivityResult(
|
|
||||||
requestCode,
|
|
||||||
resultCode,
|
|
||||||
data,
|
|
||||||
this.fetchTokenRequestCode,
|
|
||||||
object : GooglePayApi.PaymentRequestCallback {
|
|
||||||
override fun onSuccess(paymentData: PaymentData) {
|
|
||||||
val boost = boostToPurchase
|
val boost = boostToPurchase
|
||||||
boostToPurchase = null
|
boostToPurchase = null
|
||||||
|
|
||||||
|
donationPaymentRepository.onActivityResult(
|
||||||
|
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||||
|
object : GooglePayApi.PaymentRequestCallback {
|
||||||
|
override fun onSuccess(paymentData: PaymentData) {
|
||||||
if (boost != null) {
|
if (boost != null) {
|
||||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||||
|
|
||||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||||
onError = { throwable ->
|
onError = { throwable ->
|
||||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||||
},
|
},
|
||||||
onComplete = {
|
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) }
|
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||||
}
|
}
|
||||||
|
@ -127,10 +127,8 @@ class BoostViewModel(
|
||||||
|
|
||||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
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) {
|
boostToPurchase = if (snapshot.isCustomAmountFocused) {
|
||||||
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
|
Boost(snapshot.customAmount)
|
||||||
} else {
|
} else {
|
||||||
snapshot.selectedBoost
|
snapshot.selectedBoost
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,14 +11,18 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
|
class SetCurrencyViewModel(private val isBoost: Boolean) : ViewModel() {
|
||||||
|
|
||||||
private val store = Store(SetCurrencyState())
|
private val store = Store(SetCurrencyState())
|
||||||
|
|
||||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
|
val defaultCurrency = if (isBoost) {
|
||||||
|
SignalStore.donationsValues().getBoostCurrency()
|
||||||
|
} else {
|
||||||
|
SignalStore.donationsValues().getSubscriptionCurrency()
|
||||||
|
}
|
||||||
|
|
||||||
store.update { state ->
|
store.update { state ->
|
||||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||||
|
|
|
@ -107,7 +107,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
|
||||||
|
|
||||||
if (controlState == ControlState.DISPLAY) {
|
if (controlState == ControlState.DISPLAY) {
|
||||||
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
|
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
|
||||||
} else {
|
} else if (controlChecked) {
|
||||||
badgeRepository.setFeaturedBadge(args.badge).subscribe()
|
badgeRepository.setFeaturedBadge(args.badge).subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,215 @@
|
||||||
|
package org.thoughtcrime.securesms.jobs;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.VisibleForTesting;
|
||||||
|
|
||||||
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.signal.donations.StripeApi;
|
||||||
|
import org.signal.zkgroup.InvalidInputException;
|
||||||
|
import org.signal.zkgroup.VerificationFailedException;
|
||||||
|
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptCredential;
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptCredentialRequestContext;
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
|
import org.thoughtcrime.securesms.subscription.SubscriptionNotification;
|
||||||
|
import org.whispersystems.libsignal.util.Pair;
|
||||||
|
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||||
|
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
||||||
|
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
|
||||||
|
* we get a response.
|
||||||
|
*/
|
||||||
|
public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||||
|
|
||||||
|
private static final String TAG = Log.tag(BoostReceiptRequestResponseJob.class);
|
||||||
|
|
||||||
|
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
|
||||||
|
|
||||||
|
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
|
||||||
|
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
|
||||||
|
|
||||||
|
private ReceiptCredentialRequestContext requestContext;
|
||||||
|
|
||||||
|
private final String paymentIntentId;
|
||||||
|
|
||||||
|
static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent) {
|
||||||
|
return new BoostReceiptRequestResponseJob(
|
||||||
|
new Parameters
|
||||||
|
.Builder()
|
||||||
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
|
.setQueue("BoostReceiptRedemption")
|
||||||
|
.setLifespan(TimeUnit.DAYS.toMillis(30))
|
||||||
|
.setMaxAttempts(Parameters.UNLIMITED)
|
||||||
|
.build(),
|
||||||
|
null,
|
||||||
|
paymentIntent.getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Pair<String, String> enqueueChain(StripeApi.PaymentIntent paymentIntent) {
|
||||||
|
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent);
|
||||||
|
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
||||||
|
|
||||||
|
ApplicationDependencies.getJobManager()
|
||||||
|
.startChain(requestReceiptJob)
|
||||||
|
.then(redeemReceiptJob)
|
||||||
|
.enqueue();
|
||||||
|
|
||||||
|
return new Pair<>(requestReceiptJob.getId(), redeemReceiptJob.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private BoostReceiptRequestResponseJob(@NonNull Parameters parameters,
|
||||||
|
@Nullable ReceiptCredentialRequestContext requestContext,
|
||||||
|
@NonNull String paymentIntentId)
|
||||||
|
{
|
||||||
|
super(parameters);
|
||||||
|
this.requestContext = requestContext;
|
||||||
|
this.paymentIntentId = paymentIntentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NonNull Data serialize() {
|
||||||
|
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId);
|
||||||
|
|
||||||
|
if (requestContext != null) {
|
||||||
|
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NonNull String getFactoryKey() {
|
||||||
|
return KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure() {
|
||||||
|
SubscriptionNotification.VerificationFailed.INSTANCE.show(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onRun() throws Exception {
|
||||||
|
if (requestContext == null) {
|
||||||
|
SecureRandom secureRandom = new SecureRandom();
|
||||||
|
byte[] randomBytes = new byte[ReceiptSerial.SIZE];
|
||||||
|
|
||||||
|
secureRandom.nextBytes(randomBytes);
|
||||||
|
|
||||||
|
ReceiptSerial receiptSerial = new ReceiptSerial(randomBytes);
|
||||||
|
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||||
|
|
||||||
|
requestContext = operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial);
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceResponse<ReceiptCredentialResponse> response = ApplicationDependencies.getDonationsService()
|
||||||
|
.submitBoostReceiptCredentialRequest(paymentIntentId, requestContext.getRequest())
|
||||||
|
.blockingGet();
|
||||||
|
|
||||||
|
if (response.getApplicationError().isPresent()) {
|
||||||
|
if (response.getStatus() == 204) {
|
||||||
|
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Encountered a permanent failure: " + response.getStatus(), response.getApplicationError().get());
|
||||||
|
throw new Exception(response.getApplicationError().get());
|
||||||
|
}
|
||||||
|
} else if (response.getResult().isPresent()) {
|
||||||
|
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||||
|
|
||||||
|
if (!isCredentialValid(receiptCredential)) {
|
||||||
|
throw new IOException("Could not validate receipt credential");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
|
||||||
|
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
|
||||||
|
receiptCredentialPresentation.serialize())
|
||||||
|
.build());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull());
|
||||||
|
throw new RetryableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
|
||||||
|
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return operations.createReceiptCredentialPresentation(receiptCredential);
|
||||||
|
} catch (VerificationFailedException e) {
|
||||||
|
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e);
|
||||||
|
requestContext = null;
|
||||||
|
throw new RetryableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiptCredential getReceiptCredential(@NonNull ReceiptCredentialResponse response) throws RetryableException {
|
||||||
|
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return operations.receiveReceiptCredential(requestContext, response);
|
||||||
|
} catch (VerificationFailedException e) {
|
||||||
|
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e);
|
||||||
|
requestContext = null;
|
||||||
|
throw new RetryableException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that the generated Receipt Credential has the following characteristics
|
||||||
|
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
|
||||||
|
* - expiration time should have the following characteristics:
|
||||||
|
* - expiration_time mod 86400 == 0
|
||||||
|
* - expiration_time is between now and 60 days from now
|
||||||
|
*/
|
||||||
|
private boolean isCredentialValid(@NonNull ReceiptCredential receiptCredential) {
|
||||||
|
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||||
|
long monthFromNow = now + TimeUnit.DAYS.toSeconds(60);
|
||||||
|
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == 1;
|
||||||
|
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
||||||
|
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
|
||||||
|
boolean isExpirationWithinAMonth = receiptCredential.getReceiptExpirationTime() < monthFromNow;
|
||||||
|
|
||||||
|
return isCorrectLevel && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinAMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||||
|
return e instanceof RetryableException;
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting final static class RetryableException extends Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
|
||||||
|
@Override
|
||||||
|
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||||
|
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (data.hasString(DATA_REQUEST_BYTES)) {
|
||||||
|
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
||||||
|
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
||||||
|
|
||||||
|
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId);
|
||||||
|
} else {
|
||||||
|
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId);
|
||||||
|
}
|
||||||
|
} catch (InvalidInputException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,11 +4,13 @@ import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
|
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
|
||||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||||
|
@ -27,7 +29,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||||
public static final String KEY = "DonationReceiptRedemptionJob";
|
public static final String KEY = "DonationReceiptRedemptionJob";
|
||||||
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
|
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
|
||||||
|
|
||||||
public static DonationReceiptRedemptionJob createJob() {
|
public static DonationReceiptRedemptionJob createJobForSubscription() {
|
||||||
return new DonationReceiptRedemptionJob(
|
return new DonationReceiptRedemptionJob(
|
||||||
new Job.Parameters
|
new Job.Parameters
|
||||||
.Builder()
|
.Builder()
|
||||||
|
@ -39,6 +41,17 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DonationReceiptRedemptionJob createJobForBoost() {
|
||||||
|
return new DonationReceiptRedemptionJob(
|
||||||
|
new Job.Parameters
|
||||||
|
.Builder()
|
||||||
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
|
.setQueue("BoostReceiptRedemption")
|
||||||
|
.setMaxAttempts(Parameters.UNLIMITED)
|
||||||
|
.setLifespan(TimeUnit.DAYS.toMillis(30))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
private DonationReceiptRedemptionJob(@NonNull Job.Parameters parameters) {
|
private DonationReceiptRedemptionJob(@NonNull Job.Parameters parameters) {
|
||||||
super(parameters);
|
super(parameters);
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,7 @@ public final class JobManagerFactories {
|
||||||
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
||||||
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
|
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
|
||||||
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
|
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
|
||||||
|
put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory());
|
||||||
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
|
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
|
||||||
|
|
||||||
// Migrations
|
// Migrations
|
||||||
|
|
|
@ -67,7 +67,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
||||||
public static Pair<String, String> enqueueSubscriptionContinuation() {
|
public static Pair<String, String> enqueueSubscriptionContinuation() {
|
||||||
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
||||||
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId());
|
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId());
|
||||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJob();
|
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription();
|
||||||
|
|
||||||
ApplicationDependencies.getJobManager()
|
ApplicationDependencies.getJobManager()
|
||||||
.startChain(requestReceiptJob)
|
.startChain(requestReceiptJob)
|
||||||
|
|
|
@ -60,6 +60,11 @@ public final class FiatMoneyUtil {
|
||||||
formatter.setMinimumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
|
formatter.setMinimumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.trimZerosAfterDecimal) {
|
||||||
|
formatter.setMinimumFractionDigits(0);
|
||||||
|
formatter.setMaximumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
|
||||||
|
}
|
||||||
|
|
||||||
String formattedAmount = formatter.format(amount.getAmount());
|
String formattedAmount = formatter.format(amount.getAmount());
|
||||||
if (amount.getTimestamp() > 0 && options.displayTime) {
|
if (amount.getTimestamp() > 0 && options.displayTime) {
|
||||||
return resources.getString(R.string.CurrencyAmountFormatter_s_at_s,
|
return resources.getString(R.string.CurrencyAmountFormatter_s_at_s,
|
||||||
|
@ -108,6 +113,7 @@ public final class FiatMoneyUtil {
|
||||||
public static class FormatOptions {
|
public static class FormatOptions {
|
||||||
private boolean displayTime = true;
|
private boolean displayTime = true;
|
||||||
private boolean withSymbol = true;
|
private boolean withSymbol = true;
|
||||||
|
private boolean trimZerosAfterDecimal = false;
|
||||||
|
|
||||||
private FormatOptions() {
|
private FormatOptions() {
|
||||||
}
|
}
|
||||||
|
@ -121,5 +127,10 @@ public final class FiatMoneyUtil {
|
||||||
this.withSymbol = false;
|
this.withSymbol = false;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @NonNull FormatOptions trimZerosAfterDecimal() {
|
||||||
|
this.trimZerosAfterDecimal = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||||
import org.whispersystems.libsignal.logging.Log;
|
import org.whispersystems.libsignal.logging.Log;
|
||||||
import org.whispersystems.libsignal.util.Pair;
|
import org.whispersystems.libsignal.util.Pair;
|
||||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
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.push.exceptions.NonSuccessfulResponseCodeException;
|
||||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
||||||
|
@ -19,6 +20,9 @@ import org.whispersystems.signalservice.internal.push.DonationIntentResult;
|
||||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.annotations.NonNull;
|
import io.reactivex.rxjava3.annotations.NonNull;
|
||||||
import io.reactivex.rxjava3.core.Single;
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
@ -73,8 +77,33 @@ public class DonationsService {
|
||||||
* @param currencyCode The currency code for the amount
|
* @param currencyCode The currency code for the amount
|
||||||
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
|
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
|
||||||
*/
|
*/
|
||||||
public Single<ServiceResponse<DonationIntentResult>> createDonationIntentWithAmount(String amount, String currencyCode) {
|
public Single<ServiceResponse<SubscriptionClientSecret>> createDonationIntentWithAmount(String amount, String currencyCode) {
|
||||||
return createServiceResponse(() -> new Pair<>(pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200));
|
return createServiceResponse(() -> new Pair<>(pushServiceSocket.createBoostPaymentMethod(currencyCode, Long.parseLong(amount)), 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a completed payment intent and a receipt credential request produces a receipt credential response.
|
||||||
|
* Clients should always use the same ReceiptCredentialRequest with the same payment intent id. This request is repeatable so long as the two values are reused.
|
||||||
|
*
|
||||||
|
* @param paymentIntentId PaymentIntent ID from a boost donation intent response.
|
||||||
|
* @param receiptCredentialRequest Client-generated request token
|
||||||
|
*/
|
||||||
|
public Single<ServiceResponse<ReceiptCredentialResponse>> submitBoostReceiptCredentialRequest(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) {
|
||||||
|
return createServiceResponse(() -> new Pair<>(pushServiceSocket.submitBoostReceiptCredentials(paymentIntentId, receiptCredentialRequest), 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The suggested amounts for Signal Boost
|
||||||
|
*/
|
||||||
|
public Single<ServiceResponse<Map<String, List<BigDecimal>>>> getBoostAmounts() {
|
||||||
|
return createServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostAmounts(), 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The badge configuration for signal boost. Expect for right now only a single level numbered 1.
|
||||||
|
*/
|
||||||
|
public Single<ServiceResponse<SignalServiceProfile.Badge>> getBoostBadge() {
|
||||||
|
return createServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostLevels().getLevels().get("1").getBadge(), 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -8,6 +8,8 @@ import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
|
||||||
|
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates a parsed APi response regardless of where it came from (WebSocket or REST). Not only
|
* Encapsulates a parsed APi response regardless of where it came from (WebSocket or REST). Not only
|
||||||
* includes the success result but also any application errors encountered (404s, parsing, etc.) or
|
* includes the success result but also any application errors encountered (404s, parsing, etc.) or
|
||||||
|
@ -68,6 +70,18 @@ public final class ServiceResponse<Result> {
|
||||||
return executionError;
|
return executionError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Single<Result> flattenResult() {
|
||||||
|
if (result.isPresent()) {
|
||||||
|
return Single.just(result.get());
|
||||||
|
} else if (applicationError.isPresent()) {
|
||||||
|
return Single.error(applicationError.get());
|
||||||
|
} else if (executionError.isPresent()) {
|
||||||
|
return Single.error(executionError.get());
|
||||||
|
} else {
|
||||||
|
return Single.error(new AssertionError("Should never get here."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static <T> ServiceResponse<T> forResult(T result, WebsocketResponse response) {
|
public static <T> ServiceResponse<T> forResult(T result, WebsocketResponse response) {
|
||||||
return new ServiceResponse<>(result, response);
|
return new ServiceResponse<>(result, response);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.whispersystems.signalservice.internal.push;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import org.signal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||||
|
import org.whispersystems.util.Base64;
|
||||||
|
|
||||||
|
class BoostReceiptCredentialRequestJson {
|
||||||
|
@JsonProperty("paymentIntentId")
|
||||||
|
private final String paymentIntentId;
|
||||||
|
|
||||||
|
@JsonProperty("receiptCredentialRequest")
|
||||||
|
private final String receiptCredentialRequest;
|
||||||
|
|
||||||
|
BoostReceiptCredentialRequestJson(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) {
|
||||||
|
this.paymentIntentId = paymentIntentId;
|
||||||
|
this.receiptCredentialRequest = Base64.encodeBytes(receiptCredentialRequest.serialize());
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ package org.whispersystems.signalservice.internal.push;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.google.protobuf.InvalidProtocolBufferException;
|
import com.google.protobuf.InvalidProtocolBufferException;
|
||||||
import com.google.protobuf.MessageLite;
|
import com.google.protobuf.MessageLite;
|
||||||
|
|
||||||
|
@ -133,6 +134,7 @@ import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.UnsupportedEncodingException;
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.security.KeyManagementException;
|
import java.security.KeyManagementException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
|
@ -242,7 +244,6 @@ public class PushServiceSocket {
|
||||||
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
|
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
|
||||||
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
|
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
|
||||||
|
|
||||||
private static final String DONATION_INTENT = "/v1/donation/authorize-apple-pay";
|
|
||||||
private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt";
|
private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt";
|
||||||
|
|
||||||
private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels";
|
private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels";
|
||||||
|
@ -251,6 +252,10 @@ public class PushServiceSocket {
|
||||||
private static final String CREATE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method";
|
private static final String CREATE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method";
|
||||||
private static final String DEFAULT_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s";
|
private static final String DEFAULT_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s";
|
||||||
private static final String SUBSCRIPTION_RECEIPT_CREDENTIALS = "/v1/subscription/%s/receipt_credentials";
|
private static final String SUBSCRIPTION_RECEIPT_CREDENTIALS = "/v1/subscription/%s/receipt_credentials";
|
||||||
|
private static final String BOOST_AMOUNTS = "/v1/subscription/boost/amounts";
|
||||||
|
private static final String CREATE_BOOST_PAYMENT_INTENT = "/v1/subscription/boost/create";
|
||||||
|
private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials";
|
||||||
|
private static final String BOOST_BADGES = "/v1/subscription/boost/badges";
|
||||||
|
|
||||||
private static final String REPORT_SPAM = "/v1/messages/report/%s/%s";
|
private static final String REPORT_SPAM = "/v1/messages/report/%s/%s";
|
||||||
|
|
||||||
|
@ -870,15 +875,42 @@ public class PushServiceSocket {
|
||||||
makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload);
|
makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public SubscriptionClientSecret createBoostPaymentMethod(String currencyCode, long amount) throws IOException {
|
||||||
* @return The PaymentIntent id
|
String payload = JsonUtil.toJson(new DonationIntentPayload(amount, currencyCode));
|
||||||
*/
|
String result = makeServiceRequestWithoutAuthentication(CREATE_BOOST_PAYMENT_INTENT, "POST", payload);
|
||||||
public DonationIntentResult createDonationIntentWithAmount(String amount, String currencyCode) throws IOException {
|
return JsonUtil.fromJsonResponse(result, SubscriptionClientSecret.class);
|
||||||
String payload = JsonUtil.toJson(new DonationIntentPayload(Long.parseLong(amount), currencyCode.toLowerCase(Locale.ROOT)));
|
|
||||||
String result = makeServiceRequest(DONATION_INTENT, "POST", payload);
|
|
||||||
return JsonUtil.fromJsonResponse(result, DonationIntentResult.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, List<BigDecimal>> getBoostAmounts() throws IOException {
|
||||||
|
String result = makeServiceRequestWithoutAuthentication(BOOST_AMOUNTS, "GET", null);
|
||||||
|
TypeReference<HashMap<String, List<BigDecimal>>> typeRef = new TypeReference<HashMap<String, List<BigDecimal>>>() {};
|
||||||
|
return JsonUtil.fromJsonResponse(result, typeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionLevels getBoostLevels() throws IOException {
|
||||||
|
String result = makeServiceRequestWithoutAuthentication(BOOST_BADGES, "GET", null);
|
||||||
|
return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceiptCredentialResponse submitBoostReceiptCredentials(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) throws IOException {
|
||||||
|
String payload = JsonUtil.toJson(new BoostReceiptCredentialRequestJson(paymentIntentId, receiptCredentialRequest));
|
||||||
|
String response = makeServiceRequestWithoutAuthentication(
|
||||||
|
BOOST_RECEIPT_CREDENTIALS,
|
||||||
|
"POST",
|
||||||
|
payload,
|
||||||
|
(code, body) -> {
|
||||||
|
if (code == 204) throw new NonSuccessfulResponseCodeException(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
ReceiptCredentialResponseJson responseJson = JsonUtil.fromJson(response, ReceiptCredentialResponseJson.class);
|
||||||
|
if (responseJson.getReceiptCredentialResponse() != null) {
|
||||||
|
return responseJson.getReceiptCredentialResponse();
|
||||||
|
} else {
|
||||||
|
throw new MalformedResponseException("Unable to parse response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public SubscriptionLevels getSubscriptionLevels() throws IOException {
|
public SubscriptionLevels getSubscriptionLevels() throws IOException {
|
||||||
String result = makeServiceRequestWithoutAuthentication(SUBSCRIPTION_LEVELS, "GET", null);
|
String result = makeServiceRequestWithoutAuthentication(SUBSCRIPTION_LEVELS, "GET", null);
|
||||||
return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class);
|
return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class);
|
||||||
|
|
|
@ -10,6 +10,7 @@ package org.whispersystems.signalservice.internal.util;
|
||||||
import com.fasterxml.jackson.core.JsonGenerator;
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
import com.fasterxml.jackson.core.JsonParser;
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||||
|
@ -52,6 +53,22 @@ public class JsonUtil {
|
||||||
return objectMapper.readValue(json, clazz);
|
return objectMapper.readValue(json, clazz);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJson(String json, TypeReference<T> typeRef)
|
||||||
|
throws IOException
|
||||||
|
{
|
||||||
|
return objectMapper.readValue(json, typeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJsonResponse(String json, TypeReference<T> typeRef)
|
||||||
|
throws MalformedResponseException
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return JsonUtil.fromJson(json, typeRef);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new MalformedResponseException("Unable to parse entity", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static <T> T fromJsonResponse(String body, Class<T> clazz)
|
public static <T> T fromJsonResponse(String body, Class<T> clazz)
|
||||||
throws MalformedResponseException {
|
throws MalformedResponseException {
|
||||||
try {
|
try {
|
||||||
|
|
Loading…
Add table
Reference in a new issue