Implement new APIs for Boost badging.

fork-5.53.8
Alex Hart 2021-10-28 09:11:45 -03:00 zatwierdzone przez Greyson Parrelli
rodzic 48a81da883
commit 186bd9db48
19 zmienionych plików z 457 dodań i 137 usunięć

Wyświetl plik

@ -23,7 +23,7 @@ class BadgeRepository(context: Context) {
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
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)
val recipientDatabase: RecipientDatabase = DatabaseFactory.getRecipientDatabase(context)

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -4,11 +4,13 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.badges.models.Badge;
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.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
import org.whispersystems.signalservice.internal.EmptyResponse;
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 INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
public static DonationReceiptRedemptionJob createJob() {
public static DonationReceiptRedemptionJob createJobForSubscription() {
return new DonationReceiptRedemptionJob(
new Job.Parameters
.Builder()
@ -39,6 +41,17 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.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) {
super(parameters);
}

Wyświetl plik

@ -169,6 +169,7 @@ public final class JobManagerFactories {
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory());
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
// Migrations

Wyświetl plik

@ -67,7 +67,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
public static Pair<String, String> enqueueSubscriptionContinuation() {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId());
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJob();
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription();
ApplicationDependencies.getJobManager()
.startChain(requestReceiptJob)

Wyświetl plik

@ -60,6 +60,11 @@ public final class FiatMoneyUtil {
formatter.setMinimumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
}
if (options.trimZerosAfterDecimal) {
formatter.setMinimumFractionDigits(0);
formatter.setMaximumFractionDigits(amount.getCurrency().getDefaultFractionDigits());
}
String formattedAmount = formatter.format(amount.getAmount());
if (amount.getTimestamp() > 0 && options.displayTime) {
return resources.getString(R.string.CurrencyAmountFormatter_s_at_s,
@ -106,8 +111,9 @@ public final class FiatMoneyUtil {
}
public static class FormatOptions {
private boolean displayTime = true;
private boolean withSymbol = true;
private boolean displayTime = true;
private boolean withSymbol = true;
private boolean trimZerosAfterDecimal = false;
private FormatOptions() {
}
@ -121,5 +127,10 @@ public final class FiatMoneyUtil {
this.withSymbol = false;
return this;
}
public @NonNull FormatOptions trimZerosAfterDecimal() {
this.trimZerosAfterDecimal = true;
return this;
}
}
}

Wyświetl plik

@ -6,6 +6,7 @@ import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.libsignal.util.Pair;
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.SubscriberId;
@ -19,6 +20,9 @@ import org.whispersystems.signalservice.internal.push.DonationIntentResult;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
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.core.Single;
@ -73,8 +77,33 @@ public class DonationsService {
* @param currencyCode The currency code for the amount
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
*/
public Single<ServiceResponse<DonationIntentResult>> createDonationIntentWithAmount(String amount, String currencyCode) {
return createServiceResponse(() -> new Pair<>(pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200));
public Single<ServiceResponse<SubscriptionClientSecret>> createDonationIntentWithAmount(String amount, String currencyCode) {
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));
}
/**

Wyświetl plik

@ -8,6 +8,8 @@ import org.whispersystems.signalservice.internal.websocket.WebsocketResponse;
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
* 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;
}
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) {
return new ServiceResponse<>(result, response);
}

Wyświetl plik

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

Wyświetl plik

@ -8,6 +8,7 @@ package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageLite;
@ -133,6 +134,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
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 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 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 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 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";
@ -870,15 +875,42 @@ public class PushServiceSocket {
makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload);
}
/**
* @return The PaymentIntent id
*/
public DonationIntentResult createDonationIntentWithAmount(String amount, String currencyCode) throws IOException {
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 SubscriptionClientSecret createBoostPaymentMethod(String currencyCode, long amount) throws IOException {
String payload = JsonUtil.toJson(new DonationIntentPayload(amount, currencyCode));
String result = makeServiceRequestWithoutAuthentication(CREATE_BOOST_PAYMENT_INTENT, "POST", payload);
return JsonUtil.fromJsonResponse(result, SubscriptionClientSecret.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 {
String result = makeServiceRequestWithoutAuthentication(SUBSCRIPTION_LEVELS, "GET", null);
return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class);

Wyświetl plik

@ -10,6 +10,7 @@ package org.whispersystems.signalservice.internal.util;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonDeserializer;
@ -52,6 +53,22 @@ public class JsonUtil {
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)
throws MalformedResponseException {
try {