From 424a0233c2c62f4e1b506295489a56f28af07783 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 6 Dec 2022 12:07:24 -0400 Subject: [PATCH] Implement refactor to utilize new donation configuration endpoint. --- .../securesms/ApplicationContext.java | 19 ++ .../badges/gifts/flow/GiftFlowRepository.kt | 26 +- .../badges/gifts/flow/GiftFlowViewModel.kt | 2 +- .../gifts/viewgift/ViewGiftRepository.kt | 6 +- ...nternalDonorErrorConfigurationViewModel.kt | 15 +- .../DonationsConfigurationExtensions.kt | 123 +++++++++ .../app/subscription/InAppDonations.kt | 21 +- .../subscription/MonthlyDonationRepository.kt | 42 ++- .../subscription/OneTimeDonationRepository.kt | 26 +- .../donate/DonateToSignalViewModel.kt | 2 +- .../gateway/GatewaySelectorViewModel.kt | 3 + .../detail/DonationReceiptDetailRepository.kt | 5 +- .../list/DonationReceiptListRepository.kt | 12 +- .../securesms/glide/GiftBadgeModel.kt | 6 +- .../securesms/keyvalue/DonationsValues.kt | 8 + .../securesms/util/Environment.kt | 4 + .../DonationsConfigurationExtensionsKtTest.kt | 178 +++++++++++++ .../donations_configuration_test_data.json | 245 ++++++++++++++++++ .../core/util/money}/PlatformCurrencyUtil.kt | 4 +- .../java/org/signal/donations/GooglePayApi.kt | 116 +++++---- .../api/services/DonationsService.java | 96 +++---- .../internal/push/DonationsConfiguration.java | 82 ++++++ .../internal/push/PushServiceSocket.java | 35 +-- 23 files changed, 847 insertions(+), 229 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt create mode 100644 app/src/test/resources/donations_configuration_test_data.json rename {app/src/main/java/org/thoughtcrime/securesms/util => core-util/src/main/java/org/signal/core/util/money}/PlatformCurrencyUtil.kt (94%) create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 274346285f..44a6693ede 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -16,6 +16,7 @@ */ package org.thoughtcrime.securesms; +import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; @@ -35,6 +36,8 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.AndroidLogger; import org.signal.core.util.logging.Log; import org.signal.core.util.tracing.Tracer; +import org.signal.donations.GooglePayApi; +import org.signal.donations.StripeApi; import org.signal.glide.SignalGlideCodecs; import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider; import org.signal.ringrtc.CallManager; @@ -89,6 +92,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; @@ -102,6 +106,8 @@ import java.net.SocketTimeoutException; import java.security.Security; import java.util.concurrent.TimeUnit; +import io.reactivex.rxjava3.core.CompletableObserver; +import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException; import io.reactivex.rxjava3.exceptions.UndeliverableException; import io.reactivex.rxjava3.plugins.RxJavaPlugins; @@ -176,6 +182,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addBlocking("blob-provider", this::initializeBlobProvider) .addBlocking("feature-flags", FeatureFlags::init) .addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents())) + .addNonBlocking(this::checkIsGooglePayReady) .addNonBlocking(this::cleanAvatarStorage) .addNonBlocking(this::initializeRevealableMessageManager) .addNonBlocking(this::initializePendingRetryReceiptManager) @@ -459,6 +466,18 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr AvatarPickerStorage.cleanOrphans(this); } + @SuppressLint("CheckResult") + private void checkIsGooglePayReady() { + GooglePayApi.queryIsReadyToPay( + this, + new StripeApi.Gateway(Environment.Donations.getStripeConfiguration()), + Environment.Donations.getGooglePayConfiguration() + ).subscribe( + /* onComplete = */ () -> SignalStore.donationsValues().setGooglePayReady(true), + /* onError = */ t -> SignalStore.donationsValues().setGooglePayReady(false) + ); + } + @WorkerThread private void initializeCleanup() { int deleted = SignalDatabase.attachments().deleteAbandonedPreuploadedAttachments(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt index cd8d625026..9958083b88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt @@ -5,17 +5,17 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney -import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadgeAmounts +import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.ProfileUtil import org.whispersystems.signalservice.api.profiles.SignalServiceProfile -import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.push.DonationsConfiguration import java.io.IOException import java.util.Currency import java.util.Locale @@ -29,15 +29,14 @@ class GiftFlowRepository { private val TAG = Log.tag(GiftFlowRepository::class.java) } - fun getGiftBadge(): Single> { + fun getGiftBadge(): Single> { return Single .fromCallable { ApplicationDependencies.getDonationsService() - .getGiftBadges(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } - .flatMap(ServiceResponse>::flattenResult) - .map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } } - .map { it.first() } + .flatMap { it.flattenResult() } + .map { DonationsConfiguration.GIFT_LEVEL to it.getGiftBadges().first() } .subscribeOn(Schedulers.io()) } @@ -45,20 +44,17 @@ class GiftFlowRepository { return Single .fromCallable { ApplicationDependencies.getDonationsService() - .giftAmount + .getDonationsConfiguration(Locale.getDefault()) } .subscribeOn(Schedulers.io()) .flatMap { it.flattenResult() } - .map { result -> - result - .filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) } - .mapKeys { (code, _) -> Currency.getInstance(code) } - .mapValues { (currency, price) -> FiatMoney(price, currency) } - } + .map { it.getGiftBadgeAmounts() } } /** * Verifies that the given recipient is a supported target for a gift. + * + * TODO[alex] - this needs to be incorporated into the correct flows. */ fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable { return Completable.fromAction { diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index a17e23f8bf..6d29069788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -83,7 +83,7 @@ class GiftFlowViewModel( onSuccess = { (giftLevel, giftBadge) -> store.update { it.copy( - giftLevel = giftLevel, + giftLevel = giftLevel.toLong(), giftBadge = giftBadge, stage = getLoadState(it, giftBadge = giftBadge) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt index 4707138c83..a711fa5e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt @@ -4,8 +4,8 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation -import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.getBadge import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord @@ -23,10 +23,10 @@ class ViewGiftRepository { .fromCallable { ApplicationDependencies .getDonationsService() - .getGiftBadge(Locale.getDefault(), presentation.receiptLevel) + .getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } - .map { Badges.fromServiceBadge(it) } + .map { it.getBadge(presentation.receiptLevel.toInt()) } .subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt index de44695a23..045ad46d16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt @@ -11,6 +11,9 @@ import org.signal.donations.StripeDeclineCode import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation +import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges +import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges +import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -29,28 +32,28 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() { val giftBadges: Single> = Single .fromCallable { ApplicationDependencies.getDonationsService() - .getGiftBadges(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } - .map { results -> results.values.map { Badges.fromServiceBadge(it) } } + .map { it.getGiftBadges() } .subscribeOn(Schedulers.io()) val boostBadges: Single> = Single .fromCallable { ApplicationDependencies.getDonationsService() - .getBoostBadge(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } - .map { listOf(Badges.fromServiceBadge(it)) } + .map { it.getBoostBadges() } .subscribeOn(Schedulers.io()) val subscriptionBadges: Single> = Single .fromCallable { ApplicationDependencies.getDonationsService() - .getSubscriptionLevels(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } - .map { levels -> levels.levels.values.map { Badges.fromServiceBadge(it.badge) } } + .map { config -> config.getSubscriptionLevels().values.map { Badges.fromServiceBadge(it.badge) } } .subscribeOn(Schedulers.io()) disposables += Single.zip(giftBadges, boostBadges, subscriptionBadges) { g, b, s -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt new file mode 100644 index 0000000000..ecc1c4f6cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt @@ -0,0 +1,123 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription + +import org.signal.core.util.money.FiatMoney +import org.signal.core.util.money.PlatformCurrencyUtil +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.models.Badge +import org.whispersystems.signalservice.internal.push.DonationsConfiguration +import org.whispersystems.signalservice.internal.push.DonationsConfiguration.BOOST_LEVEL +import org.whispersystems.signalservice.internal.push.DonationsConfiguration.GIFT_LEVEL +import org.whispersystems.signalservice.internal.push.DonationsConfiguration.LevelConfiguration +import org.whispersystems.signalservice.internal.push.DonationsConfiguration.SUBSCRIPTION_LEVELS +import java.math.BigDecimal +import java.util.Currency + +private const val CARD = "CARD" +private const val PAYPAL = "PAYPAL" + +/** + * Transforms the DonationsConfiguration into a Set which has been properly filtered + * for available currencies on the platform and based off user device availability. + * + * CARD - Google Pay & Credit Card + * PAYPAL - PayPal + * + * @param level The subscription level to get amounts for + * @param paymentMethodAvailability Predicate object which checks whether different payment methods are availble. + */ +fun DonationsConfiguration.getSubscriptionAmounts( + level: Int, + paymentMethodAvailability: PaymentMethodAvailability = DefaultPaymentMethodAvailability +): Set { + require(SUBSCRIPTION_LEVELS.contains(level)) + + return getFilteredCurrencies(paymentMethodAvailability).map { (code, config) -> + val amount: BigDecimal = config.subscription[level]!! + FiatMoney(amount, Currency.getInstance(code.uppercase())) + }.toSet() +} + +/** + * Currently, we only support a single gift badge at level GIFT_LEVEL + */ +fun DonationsConfiguration.getGiftBadges(): List { + val configuration = levels[GIFT_LEVEL] + return listOfNotNull(configuration?.badge?.let { Badges.fromServiceBadge(it) }) +} + +/** + * Currently, we only support a single gift badge amount per currency + */ +fun DonationsConfiguration.getGiftBadgeAmounts(paymentMethodAvailability: PaymentMethodAvailability = DefaultPaymentMethodAvailability): Map { + return getFilteredCurrencies(paymentMethodAvailability).filter { + it.value.oneTime[GIFT_LEVEL]?.isNotEmpty() == true + }.mapKeys { + Currency.getInstance(it.key.uppercase()) + }.mapValues { + FiatMoney(it.value.oneTime[GIFT_LEVEL]!!.first(), it.key) + } +} + +/** + * Currently, we only support a single boost badge at level BOOST_LEVEL + */ +fun DonationsConfiguration.getBoostBadges(): List { + val configuration = levels[BOOST_LEVEL] + return listOfNotNull(configuration?.badge?.let { Badges.fromServiceBadge(it) }) +} + +fun DonationsConfiguration.getBoostAmounts(paymentMethodAvailability: PaymentMethodAvailability = DefaultPaymentMethodAvailability): Map> { + return getFilteredCurrencies(paymentMethodAvailability).filter { + it.value.oneTime[BOOST_LEVEL]?.isNotEmpty() == true + }.mapKeys { + Currency.getInstance(it.key.uppercase()) + }.mapValues { (currency, config) -> + config.oneTime[BOOST_LEVEL]!!.map { FiatMoney(it, currency) } + } +} + +fun DonationsConfiguration.getBadge(level: Int): Badge { + require(level == GIFT_LEVEL || level == BOOST_LEVEL || SUBSCRIPTION_LEVELS.contains(level)) + return Badges.fromServiceBadge(levels[level]!!.badge) +} + +fun DonationsConfiguration.getSubscriptionLevels(): Map { + return levels.filterKeys { SUBSCRIPTION_LEVELS.contains(it) }.toSortedMap() +} + +private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailability: PaymentMethodAvailability): Map { + val userPaymentMethods = paymentMethodAvailability.toSet() + val availableCurrencyCodes = PlatformCurrencyUtil.getAvailableCurrencyCodes() + return currencies.filter { (code, config) -> + val areAllMethodsAvailable = config.supportedPaymentMethods.containsAll(userPaymentMethods) + availableCurrencyCodes.contains(code.uppercase()) && areAllMethodsAvailable + } +} + +/** + * This interface is available to ease unit testing of the extension methods in + * this file. In all normal situations, you can just allow the methods to use the + * default value. + */ +interface PaymentMethodAvailability { + fun isPayPalAvailable(): Boolean + fun isGooglePayOrCreditCardAvailable(): Boolean + + fun toSet(): Set { + val set = mutableSetOf() + if (isPayPalAvailable()) { + set.add(PAYPAL) + } + + if (isGooglePayOrCreditCardAvailable()) { + set.add(CARD) + } + + return set + } +} + +private object DefaultPaymentMethodAvailability : PaymentMethodAvailability { + override fun isPayPalAvailable(): Boolean = InAppDonations.isPayPalAvailable() + override fun isGooglePayOrCreditCardAvailable(): Boolean = InAppDonations.isCreditCardAvailable() || InAppDonations.isGooglePayAvailable() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index 8cdec90c05..a2fd02920e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -2,10 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.LocaleFeatureFlags -import org.thoughtcrime.securesms.util.PlayServicesUtil /** * Helper object to determine in-app donations availability. @@ -42,29 +41,21 @@ object InAppDonations { /** * Whether the user is in a region that supports credit cards, based off local phone number. */ - private fun isCreditCardAvailable(): Boolean { + fun isCreditCardAvailable(): Boolean { return FeatureFlags.creditCardPayments() && !LocaleFeatureFlags.isCreditCardDisabled() } /** * Whether the user is in a region that supports PayPal, based off local phone number. */ - private fun isPayPalAvailable(): Boolean { + fun isPayPalAvailable(): Boolean { return (FeatureFlags.paypalOneTimeDonations() || FeatureFlags.paypalRecurringDonations()) && !LocaleFeatureFlags.isPayPalDisabled() } /** - * Whether the user is in a region that supports GooglePay, based off local phone number. + * Whether the user is using a device that supports GooglePay, based off Wallet API and phone number. */ - private fun isGooglePayAvailable(): Boolean { - return isPlayServicesAvailable() && !LocaleFeatureFlags.isGooglePayDisabled() - } - - /** - * Whether Play Services is available. This will *not* tell you whether a user has Google Pay set up, but is - * enough information to determine whether we can display Google Pay as an option. - */ - private fun isPlayServicesAvailable(): Boolean { - return PlayServicesUtil.getPlayServicesStatus(ApplicationDependencies.getApplication()) == PlayServicesUtil.PlayServicesStatus.SUCCESS + fun isGooglePayAvailable(): Boolean { + return SignalStore.donationsValues().isGooglePayReady && !LocaleFeatureFlags.isGooglePayDisabled() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt index 1c8479544c..6c0083e1d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt @@ -4,7 +4,6 @@ import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log -import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource @@ -20,15 +19,12 @@ import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.subscription.Subscription -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId -import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels import org.whispersystems.signalservice.internal.EmptyResponse import org.whispersystems.signalservice.internal.ServiceResponse -import java.util.Currency import java.util.Locale import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -52,29 +48,23 @@ class MonthlyDonationRepository(private val donationsService: DonationsService) } } - fun getSubscriptions(): Single> = Single - .fromCallable { donationsService.getSubscriptionLevels(Locale.getDefault()) } - .subscribeOn(Schedulers.io()) - .flatMap(ServiceResponse::flattenResult) - .map { subscriptionLevels -> - subscriptionLevels.levels.map { (code, level) -> - Subscription( - id = code, - name = level.name, - badge = Badges.fromServiceBadge(level.badge), - prices = level.currencies.filter { - PlatformCurrencyUtil - .getAvailableCurrencyCodes() - .contains(it.key) - }.map { (currencyCode, price) -> - FiatMoney(price, Currency.getInstance(currencyCode)) - }.toSet(), - level = code.toInt() - ) - }.sortedBy { - it.level + fun getSubscriptions(): Single> { + return Single + .fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } + .subscribeOn(Schedulers.io()) + .flatMap { it.flattenResult() } + .map { config -> + config.getSubscriptionLevels().map { (level, levelConfig) -> + Subscription( + id = level.toString(), + level = level, + name = levelConfig.name, + badge = Badges.fromServiceBadge(levelConfig.badge), + prices = config.getSubscriptionAmounts(level) + ) + } } - } + } fun syncAccountRecord(): Completable { return Completable.fromAction { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index a4811c32a1..d5e4a87e0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -6,7 +6,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.PaymentSourceType -import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError @@ -18,12 +17,8 @@ import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil -import org.whispersystems.signalservice.api.profiles.SignalServiceProfile import org.whispersystems.signalservice.api.services.DonationsService -import org.whispersystems.signalservice.internal.ServiceResponse import org.whispersystems.signalservice.internal.push.DonationProcessor -import java.math.BigDecimal import java.util.Currency import java.util.Locale import java.util.concurrent.CountDownLatch @@ -46,14 +41,15 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) } fun getBoosts(): Single>> { - return Single.fromCallable { donationsService.boostAmounts } + return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } .subscribeOn(Schedulers.io()) - .flatMap(ServiceResponse>>::flattenResult) - .map { result -> - result - .filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) } - .mapKeys { (code, _) -> Currency.getInstance(code) } - .mapValues { (currency, prices) -> prices.map { Boost(FiatMoney(it, currency)) } } + .flatMap { it.flattenResult() } + .map { config -> + config.getBoostAmounts().mapValues { (_, value) -> + value.map { + Boost(it) + } + } } } @@ -61,11 +57,11 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) return Single .fromCallable { ApplicationDependencies.getDonationsService() - .getBoostBadge(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .subscribeOn(Schedulers.io()) - .flatMap(ServiceResponse::flattenResult) - .map(Badges::fromServiceBadge) + .flatMap { it.flattenResult() } + .map { it.getBoostBadges().first() } } fun waitForOneTimeRedemption( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index 06f64bb600..f06aba198a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -12,6 +12,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject import org.signal.core.util.StringUtil import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney +import org.signal.core.util.money.PlatformCurrencyUtil import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost @@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.subscription.Subscription import org.thoughtcrime.securesms.util.InternetConnectionObserver -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.rx.RxStore import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.SubscriberId diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index a2658a96fa..e7147ac0b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -8,6 +8,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.rx.RxStore class GatewaySelectorViewModel( @@ -39,9 +40,11 @@ class GatewaySelectorViewModel( private fun checkIfGooglePayIsAvailable() { disposables += repository.isGooglePayAvailable().subscribeBy( onComplete = { + SignalStore.donationsValues().isGooglePayReady = true store.update { it.copy(isGooglePayAvailable = true) } }, onError = { + SignalStore.donationsValues().isGooglePayReady = false store.update { it.copy(isGooglePayAvailable = false) } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt index 548e852e24..a211fd5252 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.receipts import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DonationReceiptRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies @@ -13,10 +14,10 @@ class DonationReceiptDetailRepository { .fromCallable { ApplicationDependencies .getDonationsService() - .getSubscriptionLevels(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } - .map { it.levels[subscriptionLevel.toString()] ?: throw Exception("Subscription level $subscriptionLevel not found") } + .map { it.getSubscriptionLevels()[subscriptionLevel] ?: throw Exception("Subscription level $subscriptionLevel not found") } .map { it.name } .subscribeOn(Schedulers.io()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt index f24e31cfe4..371d92b9c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.receipts import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges +import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels import org.thoughtcrime.securesms.database.model.DonationReceiptRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import java.util.Locale @@ -12,23 +14,23 @@ class DonationReceiptListRepository { val boostBadges: Single> = Single .fromCallable { ApplicationDependencies.getDonationsService() - .getBoostBadge(Locale.getDefault()) + .getDonationsConfiguration(Locale.getDefault()) } .map { response -> if (response.result.isPresent) { - listOf(DonationReceiptBadge(DonationReceiptRecord.Type.BOOST, -1, Badges.fromServiceBadge(response.result.get()))) + listOf(DonationReceiptBadge(DonationReceiptRecord.Type.BOOST, -1, response.result.get().getBoostBadges().first())) } else { emptyList() } } val subBadges: Single> = Single - .fromCallable { ApplicationDependencies.getDonationsService().getSubscriptionLevels(Locale.getDefault()) } + .fromCallable { ApplicationDependencies.getDonationsService().getDonationsConfiguration(Locale.getDefault()) } .map { response -> if (response.result.isPresent) { - response.result.get().levels.map { + response.result.get().getSubscriptionLevels().map { DonationReceiptBadge( - level = it.key.toInt(), + level = it.key, badge = Badges.fromServiceBadge(it.value.badge), type = DonationReceiptRecord.Type.RECURRING ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/GiftBadgeModel.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/GiftBadgeModel.kt index d42489aa87..479b44f371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/GiftBadgeModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/GiftBadgeModel.kt @@ -11,7 +11,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory import okhttp3.OkHttpClient import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation -import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.getBadge import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import java.io.InputStream @@ -47,9 +47,9 @@ data class GiftBadgeModel(val giftBadge: GiftBadge) : Key { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { try { val receiptCredentialPresentation = ReceiptCredentialPresentation(giftBadge.giftBadge.redemptionToken.toByteArray()) - val giftBadgeResponse = ApplicationDependencies.getDonationsService().getGiftBadge(Locale.getDefault(), receiptCredentialPresentation.receiptLevel) + val giftBadgeResponse = ApplicationDependencies.getDonationsService().getDonationsConfiguration(Locale.getDefault()) if (giftBadgeResponse.result.isPresent) { - val badge = Badges.fromServiceBadge(giftBadgeResponse.result.get()) + val badge = giftBadgeResponse.result.get().getBadge(receiptCredentialPresentation.receiptLevel.toInt()) okHttpStreamFetcher = OkHttpStreamFetcher(client, GlideUrl(badge.imageUrl.toString())) okHttpStreamFetcher?.loadData(priority, callback) } else if (giftBadgeResponse.applicationError.isPresent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index c8098d764b..3d7dcc69ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -102,6 +102,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign * in determining which error messaging they should see if something goes wrong. */ private const val SUBSCRIPTION_PAYMENT_SOURCE_TYPE = "subscription.payment.source.type" + + /** + * Marked whenever we check for Google Pay availability, to help make decisions without + * awaiting the background task. + */ + private const val IS_GOOGLE_PAY_READY = "subscription.is.google.pay.ready" } override fun onFirstEverAppLaunch() = Unit @@ -354,6 +360,8 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign get() = getBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, false) set(value) = putBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, value) + var isGooglePayReady: Boolean by booleanValue(IS_GOOGLE_PAY_READY, false) + /** * Consolidates a bunch of data clears that should occur whenever a user manually cancels their * subscription: diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 47ae30efd7..4e6ca2b40b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -9,10 +9,14 @@ object Environment { const val IS_STAGING: Boolean = BuildConfig.BUILD_ENVIRONMENT_TYPE == "Staging" object Donations { + @JvmStatic + @get:JvmName("getGooglePayConfiguration") val GOOGLE_PAY_CONFIGURATION = GooglePayApi.Configuration( walletEnvironment = if (IS_STAGING) WalletConstants.ENVIRONMENT_TEST else WalletConstants.ENVIRONMENT_PRODUCTION ) + @JvmStatic + @get:JvmName("getStripeConfiguration") val STRIPE_CONFIGURATION = StripeApi.Configuration( publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt new file mode 100644 index 0000000000..42d2484bf6 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt @@ -0,0 +1,178 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.whispersystems.signalservice.internal.push.DonationsConfiguration +import org.whispersystems.signalservice.internal.util.JsonUtil +import java.util.Currency + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class DonationsConfigurationExtensionsKtTest { + + private val testData: String = javaClass.classLoader!!.getResourceAsStream("donations_configuration_test_data.json").bufferedReader().readText() + private val testSubject = JsonUtil.fromJson(testData, DonationsConfiguration::class.java) + + @Test + fun `Given all methods are available, when I getSubscriptionAmounts, then I expect BIF`() { + val subscriptionPrices = testSubject.getSubscriptionAmounts(DonationsConfiguration.SUBSCRIPTION_LEVELS.first(), AllPaymentMethodsAvailability) + + assertEquals(1, subscriptionPrices.size) + assertEquals("BIF", subscriptionPrices.first().currency.currencyCode) + } + + @Test + fun `Given only PayPal available, when I getSubscriptionAmounts, then I expect BIF and JPY`() { + val subscriptionPrices = testSubject.getSubscriptionAmounts(DonationsConfiguration.SUBSCRIPTION_LEVELS.first(), PayPalOnly) + + assertEquals(2, subscriptionPrices.size) + assertTrue(subscriptionPrices.map { it.currency.currencyCode }.containsAll(setOf("JPY", "BIF"))) + } + + @Test + fun `Given only Card available, when I getSubscriptionAmounts, then I expect BIF and USD`() { + val subscriptionPrices = testSubject.getSubscriptionAmounts(DonationsConfiguration.SUBSCRIPTION_LEVELS.first(), CardOnly) + + assertEquals(2, subscriptionPrices.size) + assertTrue(subscriptionPrices.map { it.currency.currencyCode }.containsAll(setOf("USD", "BIF"))) + } + + @Test + fun `When I getGiftBadges, then I expect exactly 1 badge with the id GIFT`() { + mockkStatic(ApplicationDependencies::class) { + every { ApplicationDependencies.getApplication() } returns ApplicationProvider.getApplicationContext() + + val giftBadges = testSubject.getGiftBadges() + + assertEquals(1, giftBadges.size) + assertTrue(giftBadges.first().isGift()) + } + } + + @Test + fun `When I getBoostBadges, then I expect exactly 1 badge with the id BOOST`() { + mockkStatic(ApplicationDependencies::class) { + every { ApplicationDependencies.getApplication() } returns ApplicationProvider.getApplicationContext() + + val boostBadges = testSubject.getBoostBadges() + + assertEquals(1, boostBadges.size) + assertTrue(boostBadges.first().isBoost()) + } + } + + @Test + fun `When I getSubscriptionLevels, then I expect the exact 3 defined subscription levels`() { + val subscriptionLevels = testSubject.getSubscriptionLevels() + + assertEquals(3, subscriptionLevels.size) + assertEquals(DonationsConfiguration.SUBSCRIPTION_LEVELS, subscriptionLevels.keys) + subscriptionLevels.keys.fold(0) { acc, i -> + assertTrue(acc < i) + i + } + } + + @Test + fun `Given all methods are available, when I getGiftAmounts, then I expect BIF`() { + val giftAmounts = testSubject.getGiftBadgeAmounts(AllPaymentMethodsAvailability) + + assertEquals(1, giftAmounts.size) + assertNotNull(giftAmounts[Currency.getInstance("BIF")]) + } + + @Test + fun `Given only PayPal available, when I getGiftAmounts, then I expect BIF and JPY`() { + val giftAmounts = testSubject.getGiftBadgeAmounts(PayPalOnly) + + assertEquals(2, giftAmounts.size) + assertTrue(giftAmounts.map { it.key.currencyCode }.containsAll(setOf("JPY", "BIF"))) + } + + @Test + fun `Given only Card available, when I getGiftAmounts, then I expect BIF and USD`() { + val giftAmounts = testSubject.getGiftBadgeAmounts(CardOnly) + + assertEquals(2, giftAmounts.size) + assertTrue(giftAmounts.map { it.key.currencyCode }.containsAll(setOf("USD", "BIF"))) + } + + @Test + fun `Given all methods are available, when I getBoostAmounts, then I expect BIF`() { + val boostAmounts = testSubject.getBoostAmounts(AllPaymentMethodsAvailability) + + assertEquals(1, boostAmounts.size) + assertNotNull(boostAmounts[Currency.getInstance("BIF")]) + } + + @Test + fun `Given only PayPal available, when I getBoostAmounts, then I expect BIF and JPY`() { + val boostAmounts = testSubject.getBoostAmounts(PayPalOnly) + + assertEquals(2, boostAmounts.size) + assertTrue(boostAmounts.map { it.key.currencyCode }.containsAll(setOf("JPY", "BIF"))) + } + + @Test + fun `Given only Card available, when I getBoostAmounts, then I expect BIF and USD`() { + val boostAmounts = testSubject.getBoostAmounts(CardOnly) + + assertEquals(2, boostAmounts.size) + assertTrue(boostAmounts.map { it.key.currencyCode }.containsAll(setOf("USD", "BIF"))) + } + + @Test + fun `Given GIFT_LEVEL, When I getBadge, then I expect the gift badge`() { + mockkStatic(ApplicationDependencies::class) { + every { ApplicationDependencies.getApplication() } returns ApplicationProvider.getApplicationContext() + val badge = testSubject.getBadge(DonationsConfiguration.GIFT_LEVEL) + + assertTrue(badge.isGift()) + } + } + + @Test + fun `Given BOOST_LEVEL, When I getBadge, then I expect the boost badge`() { + mockkStatic(ApplicationDependencies::class) { + every { ApplicationDependencies.getApplication() } returns ApplicationProvider.getApplicationContext() + val badge = testSubject.getBadge(DonationsConfiguration.BOOST_LEVEL) + + assertTrue(badge.isBoost()) + } + } + + @Test + fun `Given a sub level, When I getBadge, then I expect a sub badge`() { + mockkStatic(ApplicationDependencies::class) { + every { ApplicationDependencies.getApplication() } returns ApplicationProvider.getApplicationContext() + val badge = testSubject.getBadge(DonationsConfiguration.SUBSCRIPTION_LEVELS.first()) + + assertTrue(badge.isSubscription()) + } + } + + private object AllPaymentMethodsAvailability : PaymentMethodAvailability { + override fun isPayPalAvailable(): Boolean = true + override fun isGooglePayOrCreditCardAvailable(): Boolean = true + } + + private object PayPalOnly : PaymentMethodAvailability { + override fun isPayPalAvailable(): Boolean = true + override fun isGooglePayOrCreditCardAvailable(): Boolean = false + } + + private object CardOnly : PaymentMethodAvailability { + override fun isPayPalAvailable(): Boolean = false + override fun isGooglePayOrCreditCardAvailable(): Boolean = true + } +} diff --git a/app/src/test/resources/donations_configuration_test_data.json b/app/src/test/resources/donations_configuration_test_data.json new file mode 100644 index 0000000000..1a4b536e00 --- /dev/null +++ b/app/src/test/resources/donations_configuration_test_data.json @@ -0,0 +1,245 @@ +{ + "currencies": { + "JPY": { + "minimum": 300, + "oneTime": { + "1": [ + 500, + 600, + 700, + 800, + 900, + 1000 + ], + "100": [ + 3000 + ] + }, + "subscription": { + "2000": 35000, + "1000": 15000, + "500": 5000 + }, + "supportedPaymentMethods": [ + "PAYPAL" + ] + }, + "USD": { + "minimum": 2.5, + "oneTime": { + "1": [ + 5.5, + 6, + 7, + 8, + 9, + 10 + ], + "100": [ + 20 + ] + }, + "subscription": { + "2000": 35, + "1000": 15, + "500": 5 + }, + "supportedPaymentMethods": [ + "CARD" + ] + }, + "BIF": { + "minimum": 3000, + "oneTime": { + "1": [ + 5000, + 6000, + 7000, + 8000, + 9000, + 10000 + ], + "100": [ + 50000 + ] + }, + "subscription": { + "2000": 350000, + "1000": 150000, + "500": 50000 + }, + "supportedPaymentMethods": [ + "CARD", "PAYPAL" + ] + } + }, + "levels": { + "1": { + "name": "ZBOOST", + "badge": { + "id": "BOOST", + "category": "boost1", + "name": "boost1", + "description": "boost1", + "sprites6": [ + "l", + "m", + "h", + "x", + "xx", + "xxx" + ], + "svg": "SVG", + "svgs": [ + { + "light": "sl", + "dark": "sd" + }, + { + "light": "ml", + "dark": "md" + }, + { + "light": "ll", + "dark": "ld" + } + ], + "duration": 2592000, + "imageUrl": "" + } + }, + "100": { + "name": "ZGIFT", + "badge": { + "id": "GIFT", + "category": "gift1", + "name": "gift1", + "description": "gift1", + "sprites6": [ + "l", + "m", + "h", + "x", + "xx", + "xxx" + ], + "svg": "SVG", + "svgs": [ + { + "light": "sl", + "dark": "sd" + }, + { + "light": "ml", + "dark": "md" + }, + { + "light": "ll", + "dark": "ld" + } + ], + "duration": 5184000, + "imageUrl": "" + } + }, + "2000": { + "name": "Z3", + "badge": { + "id": "B3", + "category": "cat3", + "name": "name3", + "description": "desc3", + "sprites6": [ + "l", + "m", + "h", + "x", + "xx", + "xxx" + ], + "svg": "SVG", + "svgs": [ + { + "light": "sl", + "dark": "sd" + }, + { + "light": "ml", + "dark": "md" + }, + { + "light": "ll", + "dark": "ld" + } + ], + "imageUrl": "" + } + }, + "1000": { + "name": "Z2", + "badge": { + "id": "B2", + "category": "cat2", + "name": "name2", + "description": "desc2", + "sprites6": [ + "l", + "m", + "h", + "x", + "xx", + "xxx" + ], + "svg": "SVG", + "svgs": [ + { + "light": "sl", + "dark": "sd" + }, + { + "light": "ml", + "dark": "md" + }, + { + "light": "ll", + "dark": "ld" + } + ], + "imageUrl": "" + } + }, + "500": { + "name": "Z1", + "badge": { + "id": "B1", + "category": "cat1", + "name": "name1", + "description": "desc1", + "sprites6": [ + "l", + "m", + "h", + "x", + "xx", + "xxx" + ], + "svg": "SVG", + "svgs": [ + { + "light": "sl", + "dark": "sd" + }, + { + "light": "ml", + "dark": "md" + }, + { + "light": "ll", + "dark": "ld" + } + ], + "imageUrl": "" + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlatformCurrencyUtil.kt b/core-util/src/main/java/org/signal/core/util/money/PlatformCurrencyUtil.kt similarity index 94% rename from app/src/main/java/org/thoughtcrime/securesms/util/PlatformCurrencyUtil.kt rename to core-util/src/main/java/org/signal/core/util/money/PlatformCurrencyUtil.kt index 4b096d4a22..ab6c4ed459 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PlatformCurrencyUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/money/PlatformCurrencyUtil.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.util +package org.signal.core.util.money import java.util.Currency @@ -21,4 +21,4 @@ object PlatformCurrencyUtil { fun getAvailableCurrencyCodes(): Set { return Currency.getAvailableCurrencies().map { it.currencyCode }.toSet() } -} +} \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt b/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt index 6296295f4b..c6512878a2 100644 --- a/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt @@ -1,6 +1,7 @@ package org.signal.donations import android.app.Activity +import android.content.Context import android.content.Intent import com.google.android.gms.common.api.ApiException import com.google.android.gms.tasks.Task @@ -28,7 +29,7 @@ import java.util.Locale class GooglePayApi( private val activity: Activity, private val gateway: Gateway, - configuration: Configuration + private val configuration: Configuration ) { private val paymentsClient: PaymentsClient @@ -41,33 +42,9 @@ class GooglePayApi( paymentsClient = Wallet.getPaymentsClient(activity, walletOptions) } - /** - * Query the Google Pay API to determine whether or not the device has Google Pay available and ready. - * - * @return A completable which, when it completes, indicates that Google Pay is available, or when it errors, indicates it is not. - */ - fun queryIsReadyToPay(): Completable = Completable.create { emitter -> - try { - val request: IsReadyToPayRequest = buildIsReadyToPayRequest() - val task: Task = paymentsClient.isReadyToPay(request) - task.addOnCompleteListener { completedTask -> - if (!emitter.isDisposed) { - try { - val result: Boolean = completedTask.getResult(ApiException::class.java) ?: false - if (result) { - emitter.onComplete() - } else { - emitter.onError(Exception("Google Pay is not available.")) - } - } catch (e: ApiException) { - emitter.onError(e) - } - } - } - } catch (e: JSONException) { - emitter.onError(e) - } - }.subscribeOn(Schedulers.io()) + fun queryIsReadyToPay(): Completable { + return Companion.queryIsReadyToPay(activity, gateway, configuration) + } /** * Launches the Google Pay sheet via an Activity intent. It is up to the caller to pass @@ -132,14 +109,6 @@ class GooglePayApi( } } - private fun buildIsReadyToPayRequest(): IsReadyToPayRequest { - val isReadyToPayJson: JSONObject = baseRequest.apply { - put("allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod())) - } - - return IsReadyToPayRequest.fromJson(isReadyToPayJson.toString()) - } - private fun gatewayTokenizationSpecification(): JSONObject { return JSONObject().apply { put("type", "PAYMENT_GATEWAY") @@ -147,21 +116,8 @@ class GooglePayApi( } } - private fun baseCardPaymentMethod(): JSONObject { - return JSONObject().apply { - val parameters = JSONObject().apply { - put("allowedAuthMethods", allowedCardAuthMethods) - put("allowedCardNetworks", JSONArray(gateway.allowedCardNetworks)) - put("billingAddressRequired", false) - } - - put("type", "CARD") - put("parameters", parameters) - } - } - private fun cardPaymentMethod(): JSONObject { - val cardPaymentMethod = baseCardPaymentMethod() + val cardPaymentMethod = baseCardPaymentMethod(gateway) cardPaymentMethod.put("tokenizationSpecification", gatewayTokenizationSpecification()) return cardPaymentMethod @@ -181,6 +137,66 @@ class GooglePayApi( put("apiVersion", 2) put("apiVersionMinor", 0) } + + /** + * Query the Google Pay API to determine whether or not the device has Google Pay available and ready. + * + * @return A completable which, when it completes, indicates that Google Pay is available, or when it errors, indicates it is not. + */ + @JvmStatic + fun queryIsReadyToPay( + context: Context, + gateway: Gateway, + configuration: Configuration + ): Completable = Completable.create { emitter -> + val walletOptions = Wallet.WalletOptions.Builder() + .setEnvironment(configuration.walletEnvironment) + .build() + + val paymentsClient = Wallet.getPaymentsClient(context, walletOptions) + + try { + val request: IsReadyToPayRequest = buildIsReadyToPayRequest(gateway) + val task: Task = paymentsClient.isReadyToPay(request) + task.addOnCompleteListener { completedTask -> + if (!emitter.isDisposed) { + try { + val result: Boolean = completedTask.getResult(ApiException::class.java) ?: false + if (result) { + emitter.onComplete() + } else { + emitter.onError(Exception("Google Pay is not available.")) + } + } catch (e: ApiException) { + emitter.onError(e) + } + } + } + } catch (e: JSONException) { + emitter.onError(e) + } + }.subscribeOn(Schedulers.io()) + + private fun buildIsReadyToPayRequest(gateway: Gateway): IsReadyToPayRequest { + val isReadyToPayJson: JSONObject = baseRequest.apply { + put("allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod(gateway))) + } + + return IsReadyToPayRequest.fromJson(isReadyToPayJson.toString()) + } + + private fun baseCardPaymentMethod(gateway: Gateway): JSONObject { + return JSONObject().apply { + val parameters = JSONObject().apply { + put("allowedAuthMethods", allowedCardAuthMethods) + put("allowedCardNetworks", JSONArray(gateway.allowedCardNetworks)) + put("billingAddressRequired", false) + } + + put("type", "CARD") + put("parameters", parameters) + } + } } /** 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 7ccc1fbe22..74dcc6fbb6 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,7 +6,6 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; 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.PayPalConfirmPaymentIntentResponse; @@ -14,21 +13,19 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentInt import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse; import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret; import org.whispersystems.signalservice.api.subscriptions.SubscriberId; -import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels; import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.whispersystems.signalservice.internal.push.DonationProcessor; +import org.whispersystems.signalservice.internal.push.DonationsConfiguration; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import java.io.IOException; -import java.math.BigDecimal; -import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; -import java.util.TreeMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import io.reactivex.rxjava3.annotations.NonNull; @@ -41,6 +38,20 @@ public class DonationsService { private final PushServiceSocket pushServiceSocket; + private final AtomicReference donationsConfigurationCache = new AtomicReference<>(null); + + private static class CacheEntry { + private final DonationsConfiguration donationsConfiguration; + private final long expiresAt; + private final Locale locale; + + private CacheEntry(DonationsConfiguration donationsConfiguration, long expiresAt, Locale locale) { + this.donationsConfiguration = donationsConfiguration; + this.expiresAt = expiresAt; + this.locale = locale; + } + } + public DonationsService( SignalServiceConfiguration configuration, CredentialsProvider credentialsProvider, @@ -95,61 +106,24 @@ public class DonationsService { return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.submitBoostReceiptCredentials(paymentIntentId, receiptCredentialRequest, processor), 200)); } - /** - * @return The suggested amounts for Signal Boost - */ - public ServiceResponse>> getBoostAmounts() { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostAmounts(), 200)); - } - - /** - * @return The badge configuration for signal boost. Expect for right now only a single level numbered 1. - */ - public ServiceResponse getBoostBadge(Locale locale) { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostLevels(locale).getLevels().get(SubscriptionLevels.BOOST_LEVEL).getBadge(), 200)); - } - - /** - * @return A specific gift badge, by level. - */ - public ServiceResponse getGiftBadge(Locale locale, long level) { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.getBoostLevels(locale).getLevels().get(String.valueOf(level)).getBadge(), 200)); - } - - /** - * @return All gift badges the server currently has available. - */ - public ServiceResponse> getGiftBadges(Locale locale) { - return wrapInServiceResponse(() -> { - Map levels = pushServiceSocket.getBoostLevels(locale).getLevels(); - Map badges = new TreeMap<>(); - - for (Map.Entry levelEntry : levels.entrySet()) { - if (!Objects.equals(levelEntry.getKey(), SubscriptionLevels.BOOST_LEVEL)) { - try { - badges.put(Long.parseLong(levelEntry.getKey()), levelEntry.getValue().getBadge()); - } catch (NumberFormatException e) { - Log.w(TAG, "Could not parse gift badge for level entry " + levelEntry.getKey(), e); - } + public ServiceResponse getDonationsConfiguration(Locale locale) { + CacheEntry cacheEntryOutsideLock = donationsConfigurationCache.get(); + if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) { + synchronized (this) { + CacheEntry cacheEntryInLock = donationsConfigurationCache.get(); + if (isNewCacheEntryRequired(cacheEntryInLock, locale)) { + return wrapInServiceResponse(() -> { + DonationsConfiguration donationsConfiguration = pushServiceSocket.getDonationsConfiguration(locale); + donationsConfigurationCache.set(new CacheEntry(donationsConfiguration, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale)); + return new Pair<>(donationsConfiguration, 200); + }); + } else { + return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.donationsConfiguration, 200)); } } - - return new Pair<>(badges, 200); - }); - } - - /** - * Returns the amounts for the gift badge. - */ - public ServiceResponse> getGiftAmount() { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.getGiftAmount(), 200)); - } - - /** - * Returns the subscription levels that are available for the client to choose from along with currencies and current prices - */ - public ServiceResponse getSubscriptionLevels(Locale locale) { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.getSubscriptionLevels(locale), 200)); + } else { + return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.donationsConfiguration, 200)); + } } /** @@ -364,6 +338,10 @@ public class DonationsService { } } + private boolean isNewCacheEntryRequired(CacheEntry cacheEntry, Locale locale) { + return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale); + } + private interface Producer { Pair produce() throws IOException; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java new file mode 100644 index 0000000000..2d5da71f16 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java @@ -0,0 +1,82 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Response JSON for a call to /v1/subscriptions/configuration + */ +public class DonationsConfiguration { + + public static final int BOOST_LEVEL = 1; + public static final int GIFT_LEVEL = 100; + public static final HashSet SUBSCRIPTION_LEVELS = new HashSet<>(Arrays.asList(500, 1000, 2000)); + + @JsonProperty("currencies") + private Map currencies; + + @JsonProperty("levels") + private Map levels; + + public static class CurrencyConfiguration { + @JsonProperty("minimum") + private BigDecimal minimum; + + @JsonProperty("oneTime") + private Map> oneTime; + + @JsonProperty("subscription") + private Map subscription; + + @JsonProperty("supportedPaymentMethods") + private Set supportedPaymentMethods; + + public BigDecimal getMinimum() { + return minimum; + } + + public Map> getOneTime() { + return oneTime; + } + + public Map getSubscription() { + return subscription; + } + + public Set getSupportedPaymentMethods() { + return supportedPaymentMethods; + } + } + + public static class LevelConfiguration { + @JsonProperty("name") + private String name; + + @JsonProperty("badge") + private SignalServiceProfile.Badge badge; + + public String getName() { + return name; + } + + public SignalServiceProfile.Badge getBadge() { + return badge; + } + } + + public Map getCurrencies() { + return currencies; + } + + public Map getLevels() { + return levels; + } +} \ No newline at end of file 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 1b8ca76fa1..32ffdca6f3 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 @@ -264,7 +264,6 @@ public class PushServiceSocket { private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt"; - private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels"; private static final String UPDATE_SUBSCRIPTION_LEVEL = "/v1/subscription/%s/level/%s/%s/%s"; private static final String SUBSCRIPTION = "/v1/subscription/%s"; private static final String CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method"; @@ -272,13 +271,11 @@ public class PushServiceSocket { private static final String DEFAULT_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s"; private static final String DEFAULT_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/paypal/%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 GIFT_AMOUNT = "/v1/subscription/boost/amounts/gift"; private static final String CREATE_STRIPE_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/create"; private static final String CREATE_PAYPAL_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/paypal/create"; private static final String CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/paypal/confirm"; 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 DONATIONS_CONFIGURATION = "/v1/subscription/configuration"; private static final String CDSI_AUTH = "/v2/directory/auth"; @@ -1048,28 +1045,10 @@ public class PushServiceSocket { public PayPalCreatePaymentMethodResponse createPayPalPaymentMethod(Locale locale, String subscriberId, String returnUrl, String cancelUrl) throws IOException { Map headers = Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()); String payload = JsonUtil.toJson(new PayPalCreatePaymentMethodPayload(returnUrl, cancelUrl)); - String result = makeServiceRequestWithoutAuthentication(String.format(CREATE_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", payload); + String result = makeServiceRequestWithoutAuthentication(String.format(CREATE_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", payload, headers, NO_HANDLER); return JsonUtil.fromJsonResponse(result, PayPalCreatePaymentMethodResponse.class); } - - public Map> getBoostAmounts() throws IOException { - String result = makeServiceRequestWithoutAuthentication(BOOST_AMOUNTS, "GET", null); - TypeReference>> typeRef = new TypeReference>>() {}; - return JsonUtil.fromJsonResponse(result, typeRef); - } - - public Map getGiftAmount() throws IOException { - String result = makeServiceRequestWithoutAuthentication(GIFT_AMOUNT, "GET", null); - TypeReference> typeRef = new TypeReference>() {}; - return JsonUtil.fromJsonResponse(result, typeRef); - } - - public SubscriptionLevels getBoostLevels(Locale locale) throws IOException { - String result = makeServiceRequestWithoutAuthentication(BOOST_BADGES, "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), NO_HANDLER); - return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class); - } - public ReceiptCredentialResponse submitBoostReceiptCredentials(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest, DonationProcessor processor) throws IOException { String payload = JsonUtil.toJson(new BoostReceiptCredentialRequestJson(paymentIntentId, receiptCredentialRequest, processor)); String response = makeServiceRequestWithoutAuthentication( @@ -1089,10 +1068,14 @@ public class PushServiceSocket { } } + /** + * Get the DonationsConfiguration pointed at by /v1/subscriptions/configuration + */ + public DonationsConfiguration getDonationsConfiguration(Locale locale) throws IOException { + Map headers = Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()); + String result = makeServiceRequestWithoutAuthentication(DONATIONS_CONFIGURATION, "GET", null, headers, NO_HANDLER); - public SubscriptionLevels getSubscriptionLevels(Locale locale) throws IOException { - String result = makeServiceRequestWithoutAuthentication(SUBSCRIPTION_LEVELS, "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), NO_HANDLER); - return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class); + return JsonUtil.fromJson(result, DonationsConfiguration.class); } public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException {