diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt index a8238a807..560e0a09e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeRepository.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 3dac5eb60..70777e7ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index 048dfbda6..641fdcf38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -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::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::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::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::flattenResult) + .map { + StripeApi.PaymentIntent(it.id, it.clientSecret) } } override fun fetchSetupIntent(): Single { - 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::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::flattenResult).ignoreElement() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt index 09c8fb84f..a73671b14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt @@ -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 { 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::flattenResult) } else { Single.just(ActiveSubscription(null)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index 8ccb58539..0d5cb9ea9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt index fccd5718a..3418b2c88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt @@ -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, Boost?>> { - val boosts = testBoosts(currency) - - return Single.just( - Pair( - boosts, - boosts[2] - ) - ) + fun getBoosts(currency: Currency): Single> { + return donationsService.boostAmounts + .flatMap(ServiceResponse>>::flattenResult) + .map { result -> + val boosts = result[currency.currencyCode] ?: throw Exception("Unsupported currency! ${currency.currencyCode}") + boosts.map { Boost(FiatMoney(it, currency)) } + } } - fun getBoostBadge(): Single = 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 { + return donationsService.boostBadge + .flatMap(ServiceResponse::flattenResult) + .map(Badges::fromServiceBadge) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index fb3428b6f..cd17f9e47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt index 6b7ec5e9e..b36497b0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt @@ -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 = 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt index 42a7c16e7..8657b5781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt @@ -107,7 +107,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh if (controlState == ControlState.DISPLAY) { badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe() - } else { + } else if (controlChecked) { badgeRepository.setFeaturedBadge(args.badge).subscribe() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java new file mode 100644 index 000000000..990a8cd98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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 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 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 { + @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); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index e2ddf958e..6626f2ac8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 7b414b5b8..e866f640b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 1f92523aa..3028b7f08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -67,7 +67,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { public static Pair enqueueSubscriptionContinuation() { Subscriber subscriber = SignalStore.donationsValues().requireSubscriber(); SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId()); - DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJob(); + DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(); ApplicationDependencies.getJobManager() .startChain(requestReceiptJob) diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java index be07524bf..07c41c9d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java @@ -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; + } } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index a194c1254..75b76c73a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -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> createDonationIntentWithAmount(String amount, String currencyCode) { - return createServiceResponse(() -> new Pair<>(pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200)); + public Single> 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> submitBoostReceiptCredentialRequest(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) { + return createServiceResponse(() -> new Pair<>(pushServiceSocket.submitBoostReceiptCredentials(paymentIntentId, receiptCredentialRequest), 200)); + } + + /** + * @return The suggested amounts for Signal Boost + */ + public Single>>> 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> getBoostBadge() { + return createServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostLevels().getLevels().get("1").getBadge(), 200)); } /** diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/ServiceResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/ServiceResponse.java index 7338ab346..d82af3ccd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/ServiceResponse.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/ServiceResponse.java @@ -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 { return executionError; } + public Single 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 ServiceResponse forResult(T result, WebsocketResponse response) { return new ServiceResponse<>(result, response); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java new file mode 100644 index 000000000..81328863e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java @@ -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()); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 4226cc4d2..bc18e056d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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> getBoostAmounts() throws IOException { + String result = makeServiceRequestWithoutAuthentication(BOOST_AMOUNTS, "GET", null); + TypeReference>> typeRef = new TypeReference>>() {}; + 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); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java index d0341c527..243a21af6 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/util/JsonUtil.java @@ -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 fromJson(String json, TypeReference typeRef) + throws IOException + { + return objectMapper.readValue(json, typeRef); + } + + public static T fromJsonResponse(String json, TypeReference typeRef) + throws MalformedResponseException + { + try { + return JsonUtil.fromJson(json, typeRef); + } catch (IOException e) { + throw new MalformedResponseException("Unable to parse entity", e); + } + } + public static T fromJsonResponse(String body, Class clazz) throws MalformedResponseException { try {