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 7ad1f2a7dc..409cb3b44e 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 @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.model.DonationReceiptRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.ProfileUtil @@ -110,8 +111,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) gatewayRequest: GatewayRequest, paymentIntentId: String, donationProcessor: DonationProcessor, - isLongRunning: Boolean + paymentSourceType: PaymentSourceType ): Completable { + val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit val isBoost = gatewayRequest.recipientId == Recipient.self().id val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT @@ -127,6 +129,14 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true) SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord) + SignalStore.donationsValues().setPendingOneTimeDonation( + PendingOneTimeDonationSerializer.createProto( + gatewayRequest.badge, + paymentSourceType, + gatewayRequest.fiat + ) + ) + val countDownLatch = CountDownLatch(1) var finalJobState: JobTracker.JobState? = null val chain = if (isBoost) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt new file mode 100644 index 0000000000..cdc7c7e959 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription + +import okio.ByteString +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.database.model.databaseprotos.DecimalValue +import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation +import java.math.BigDecimal +import java.math.BigInteger +import java.math.MathContext +import java.util.Currency +import kotlin.time.Duration.Companion.days + +object PendingOneTimeDonationSerializer { + + private val PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT = 14.days + private val PENDING_ONE_TIME_NORMAL_TIMEOUT = 1.days + + val PendingOneTimeDonation.isExpired: Boolean + get() { + val timeout = if (paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) { + PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT + } else { + PENDING_ONE_TIME_NORMAL_TIMEOUT + } + + return (timestamp + timeout.inWholeMilliseconds) < System.currentTimeMillis() + } + + fun createProto( + badge: Badge, + paymentSourceType: PaymentSourceType, + amount: FiatMoney + ): PendingOneTimeDonation { + return PendingOneTimeDonation( + badge = Badges.toDatabaseBadge(badge), + paymentMethodType = when (paymentSourceType) { + PaymentSourceType.PayPal -> PendingOneTimeDonation.PaymentMethodType.PAYPAL + PaymentSourceType.Stripe.CreditCard, PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Unknown -> PendingOneTimeDonation.PaymentMethodType.CARD + PaymentSourceType.Stripe.SEPADebit -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT + }, + amount = amount.toFiatValue(), + timestamp = System.currentTimeMillis() + ) + } + + fun FiatValue.toFiatMoney(): FiatMoney { + return FiatMoney( + amount!!.toBigDecimal(), + Currency.getInstance(currencyCode) + ) + } + + private fun DecimalValue.toBigDecimal(): BigDecimal { + return BigDecimal( + BigInteger(value_.toByteArray()), + scale, + MathContext(precision) + ) + } + + private fun FiatMoney.toFiatValue(): FiatValue { + return FiatValue( + currencyCode = currency.currencyCode, + amount = amount.toDecimalValue() + ) + } + + private fun BigDecimal.toDecimalValue(): DecimalValue { + return DecimalValue( + scale = scale(), + precision = precision(), + value_ = ByteString.of(*this.unscaledValue().toByteArray()) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt index 9fdb93540d..dda20f93f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -19,7 +19,7 @@ data class DonateToSignalState( val areFieldsEnabled: Boolean get() = when (donateToSignalType) { - DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY + DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY && !oneTimeDonationState.isOneTimeDonationPending DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress DonateToSignalType.GIFT -> error("This flow does not support gifts") } @@ -33,7 +33,7 @@ data class DonateToSignalState( val canSetCurrency: Boolean get() = when (donateToSignalType) { - DonateToSignalType.ONE_TIME -> areFieldsEnabled + DonateToSignalType.ONE_TIME -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive DonateToSignalType.GIFT -> error("This flow does not support gifts") } @@ -85,6 +85,7 @@ data class DonateToSignalState( val isCustomAmountFocused: Boolean = false, val donationStage: DonationStage = DonationStage.INIT, val selectableCurrencyCodes: List = emptyList(), + val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation() != null, private val minimumDonationAmounts: Map = emptyMap() ) { val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency) 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 77e6c2f163..d05ad9a058 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 @@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDo import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest -import org.thoughtcrime.securesms.components.settings.app.subscription.manage.SubscriptionRedemptionJobWatcher +import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -207,6 +207,13 @@ class DonateToSignalViewModel( } private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) { + oneTimeDonationDisposables += SignalStore.donationsValues().observablePendingOneTimeDonation + .map { it.isPresent } + .distinctUntilChanged() + .subscribe { hasPendingOneTimeDonation -> + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) } + } + oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy( onSuccess = { badge -> store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) } @@ -274,7 +281,7 @@ class DonateToSignalViewModel( } private fun monitorLevelUpdateProcessing() { - val isTransactionJobInProgress: Observable = SubscriptionRedemptionJobWatcher.watch().map { + val isTransactionJobInProgress: Observable = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map { it.map { jobState -> when (jobState) { JobTracker.JobState.PENDING -> true diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index d0a8469e9c..9e0cbf5b2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -156,7 +156,7 @@ class PayPalPaymentInProgressViewModel( gatewayRequest = request, paymentIntentId = response.paymentId, donationProcessor = DonationProcessor.PAYPAL, - isLongRunning = false + paymentSourceType = PaymentSourceType.PayPal ) } .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index 05bf271612..2797a45d7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -202,7 +202,7 @@ class StripePaymentInProgressViewModel( gatewayRequest = request, paymentIntentId = paymentIntent.intentId, donationProcessor = DonationProcessor.STRIPE, - isLongRunning = paymentSource.type.isLongRunning + paymentSourceType = paymentSource.type ) } }.subscribeBy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index 4f7548611f..a84e39b984 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import android.text.method.LinkMovementMethod -import android.view.View import android.widget.ProgressBar import android.widget.TextView import androidx.core.content.ContextCompat @@ -9,14 +8,15 @@ import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.BadgeImageView import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.subscription.Subscription import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.visible import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import java.util.Locale @@ -31,7 +31,7 @@ object ActiveSubscriptionPreference { val price: FiatMoney, val subscription: Subscription, val renewalTimestamp: Long = -1L, - val redemptionState: ManageDonationsState.SubscriptionRedemptionState, + val redemptionState: ManageDonationsState.RedemptionState, val activeSubscription: ActiveSubscription.Subscription, val onContactSupport: () -> Unit, val onPendingClick: (FiatMoney) -> Unit @@ -50,12 +50,12 @@ object ActiveSubscriptionPreference { } } - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + class ViewHolder(binding: MySupportPreferenceBinding) : BindingViewHolder(binding) { - val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge) - val title: TextView = itemView.findViewById(R.id.my_support_title) - val expiry: TextView = itemView.findViewById(R.id.my_support_expiry) - val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress) + val badge: BadgeImageView = binding.mySupportBadge + val title: TextView = binding.mySupportTitle + val expiry: TextView = binding.mySupportExpiry + val progress: ProgressBar = binding.mySupportProgress override fun bind(model: Model) { itemView.setOnClickListener(null) @@ -74,10 +74,10 @@ object ActiveSubscriptionPreference { expiry.movementMethod = LinkMovementMethod.getInstance() when (model.redemptionState) { - ManageDonationsState.SubscriptionRedemptionState.NONE -> presentRenewalState(model) - ManageDonationsState.SubscriptionRedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model) - ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState() - ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model) + ManageDonationsState.RedemptionState.NONE -> presentRenewalState(model) + ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model) + ManageDonationsState.RedemptionState.IN_PROGRESS -> presentInProgressState() + ManageDonationsState.RedemptionState.FAILED -> presentFailureState(model) } } @@ -146,6 +146,6 @@ object ActiveSubscriptionPreference { } fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference)) + adapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, MySupportPreferenceBinding::inflate)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt new file mode 100644 index 0000000000..11ca66792b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.manage + +import io.reactivex.rxjava3.core.Observable +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob +import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob +import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import java.util.Optional +import java.util.concurrent.TimeUnit + +/** + * Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions or one time payments. + */ +object DonationRedemptionJobWatcher { + + enum class RedemptionType { + SUBSCRIPTION, + ONE_TIME + } + + fun watchSubscriptionRedemption(): Observable> = watch(RedemptionType.SUBSCRIPTION) + + fun watchOneTimeRedemption(): Observable> = watch(RedemptionType.ONE_TIME) + + private fun watch(redemptionType: RedemptionType): Observable> = Observable.interval(0, 5, TimeUnit.SECONDS).map { + val queue = when (redemptionType) { + RedemptionType.SUBSCRIPTION -> DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE + RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE + } + + val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { + it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue?.startsWith(queue) == true + } + + val receiptRequestJobKey = when (redemptionType) { + RedemptionType.SUBSCRIPTION -> SubscriptionReceiptRequestResponseJob.KEY + RedemptionType.ONE_TIME -> BoostReceiptRequestResponseJob.KEY + } + + val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { + it.factoryKey == receiptRequestJobKey && it.parameters.queue?.startsWith(queue) == true + } + + val jobState: JobTracker.JobState? = redemptionJobState ?: receiptJobState + + if (redemptionType == RedemptionType.SUBSCRIPTION && jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) { + Optional.of(JobTracker.JobState.FAILURE) + } else { + Optional.ofNullable(jobState) + } + }.distinctUntilChanged() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 882914b2b6..d1e9d3d337 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -70,6 +70,7 @@ class ManageDonationsFragment : override fun bindAdapter(adapter: MappingAdapter) { ActiveSubscriptionPreference.register(adapter) + OneTimeDonationPreference.register(adapter) IndeterminateLoadingCircle.register(adapter) BadgePreview.register(adapter) NetworkFailure.register(adapter) @@ -129,34 +130,36 @@ class ManageDonationsFragment : space(16.dp) - if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) { - val activeSubscription = state.transactionState.activeSubscription.activeSubscription + if (state.subscriptionTransactionState is ManageDonationsState.TransactionState.NotInTransaction) { + val activeSubscription = state.subscriptionTransactionState.activeSubscription.activeSubscription if (activeSubscription != null) { val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == activeSubscription.level } if (subscription != null) { - presentSubscriptionSettings(activeSubscription, subscription, state.getMonthlyDonorRedemptionState()) + presentSubscriptionSettings(activeSubscription, subscription, state) } else { customPref(IndeterminateLoadingCircle) } } else if (state.hasOneTimeBadge) { - presentActiveOneTimeDonorSettings() + presentActiveOneTimeDonorSettings(state) } else { presentNotADonorSettings(state.hasReceipts) } - } else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) { - presentNetworkFailureSettings(state.getMonthlyDonorRedemptionState(), state.hasReceipts) + } else if (state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) { + presentNetworkFailureSettings(state, state.hasReceipts) } else { customPref(IndeterminateLoadingCircle) } } } - private fun DSLConfiguration.presentActiveOneTimeDonorSettings() { + private fun DSLConfiguration.presentActiveOneTimeDonorSettings(state: ManageDonationsState) { dividerPref() sectionHeaderPref(R.string.ManageDonationsFragment__my_support) + presentPendingOrProcessingOneTimeDonationState(state) + presentBadges() presentOtherWaysToGive() @@ -164,16 +167,30 @@ class ManageDonationsFragment : presentMore() } - private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState, hasReceipts: Boolean) { + private fun DSLConfiguration.presentPendingOrProcessingOneTimeDonationState(state: ManageDonationsState) { + val pendingOneTimeDonation = state.pendingOneTimeDonation + if (pendingOneTimeDonation != null) { + customPref( + OneTimeDonationPreference.Model( + pendingOneTimeDonation = pendingOneTimeDonation, + onPendingClick = { + displayPendingDialog(it) + } + ) + ) + } + } + + private fun DSLConfiguration.presentNetworkFailureSettings(state: ManageDonationsState, hasReceipts: Boolean) { if (SignalStore.donationsValues().isLikelyASustainer()) { - presentSubscriptionSettingsWithNetworkError(redemptionState) + presentSubscriptionSettingsWithNetworkError(state) } else { presentNotADonorSettings(hasReceipts) } } - private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) { - presentSubscriptionSettingsWithState(redemptionState) { + private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(state: ManageDonationsState) { + presentSubscriptionSettingsWithState(state) { customPref( NetworkFailure.Model( onRetryClick = { @@ -187,9 +204,9 @@ class ManageDonationsFragment : private fun DSLConfiguration.presentSubscriptionSettings( activeSubscription: ActiveSubscription.Subscription, subscription: Subscription, - redemptionState: ManageDonationsState.SubscriptionRedemptionState + state: ManageDonationsState ) { - presentSubscriptionSettingsWithState(redemptionState) { + presentSubscriptionSettingsWithState(state) { val activeCurrency = Currency.getInstance(activeSubscription.currency) val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits) @@ -198,7 +215,7 @@ class ManageDonationsFragment : price = FiatMoney(activeAmount, activeCurrency), subscription = subscription, renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod), - redemptionState = redemptionState, + redemptionState = state.getMonthlyDonorRedemptionState(), onContactSupport = { requireActivity().finish() requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX)) @@ -213,7 +230,7 @@ class ManageDonationsFragment : } private fun DSLConfiguration.presentSubscriptionSettingsWithState( - redemptionState: ManageDonationsState.SubscriptionRedemptionState, + state: ManageDonationsState, subscriptionBlock: DSLConfiguration.() -> Unit ) { dividerPref() @@ -222,10 +239,12 @@ class ManageDonationsFragment : subscriptionBlock() + presentPendingOrProcessingOneTimeDonationState(state) + clickPref( title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription), icon = DSLSettingsIcon.from(R.drawable.symbol_person_24), - isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS, + isEnabled = state.getMonthlyDonorRedemptionState() != ManageDonationsState.RedemptionState.IN_PROGRESS, onClick = { findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index 0a52406bd5..9d009b79c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.subscription.Subscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription @@ -8,25 +9,26 @@ data class ManageDonationsState( val hasOneTimeBadge: Boolean = false, val hasReceipts: Boolean = false, val featuredBadge: Badge? = null, - val transactionState: TransactionState = TransactionState.Init, + val subscriptionTransactionState: TransactionState = TransactionState.Init, val availableSubscriptions: List = emptyList(), - private val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE + val pendingOneTimeDonation: PendingOneTimeDonation? = null, + private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE ) { - fun getMonthlyDonorRedemptionState(): SubscriptionRedemptionState { - return when (transactionState) { + fun getMonthlyDonorRedemptionState(): RedemptionState { + return when (subscriptionTransactionState) { TransactionState.Init -> subscriptionRedemptionState TransactionState.NetworkFailure -> subscriptionRedemptionState - TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS - is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState + TransactionState.InTransaction -> RedemptionState.IN_PROGRESS + is TransactionState.NotInTransaction -> getStateFromActiveSubscription(subscriptionTransactionState.activeSubscription) ?: subscriptionRedemptionState } } - private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): SubscriptionRedemptionState? { + private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? { return when { - activeSubscription.isFailedPayment -> SubscriptionRedemptionState.FAILED - activeSubscription.isPendingBankTransfer -> SubscriptionRedemptionState.IS_PENDING_BANK_TRANSFER - activeSubscription.isInProgress -> SubscriptionRedemptionState.IN_PROGRESS + activeSubscription.isFailedPayment -> RedemptionState.FAILED + activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER + activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS else -> null } } @@ -38,7 +40,7 @@ data class ManageDonationsState( class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState() } - enum class SubscriptionRedemptionState { + enum class RedemptionState { NONE, IN_PROGRESS, IS_PENDING_BANK_TRANSFER, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index f457cd0bea..3d7c4b89be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -11,9 +11,11 @@ import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log +import org.signal.core.util.orNull import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.util.InternetConnectionObserver @@ -50,8 +52,8 @@ class ManageDonationsViewModel( } fun retry() { - if (!disposables.isDisposed && store.state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) { - store.update { it.copy(transactionState = ManageDonationsState.TransactionState.Init) } + if (!disposables.isDisposed && store.state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) { + store.update { it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.Init) } refresh() } } @@ -74,22 +76,21 @@ class ManageDonationsViewModel( store.update { it.copy(hasReceipts = hasReceipts) } } - disposables += SubscriptionRedemptionJobWatcher.watch().subscribeBy { jobStateOptional -> + disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { jobStateOptional -> store.update { manageDonationsState -> manageDonationsState.copy( - subscriptionRedemptionState = jobStateOptional.map { jobState: JobTracker.JobState -> - when (jobState) { - JobTracker.JobState.PENDING -> ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS - JobTracker.JobState.RUNNING -> ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS - JobTracker.JobState.SUCCESS -> ManageDonationsState.SubscriptionRedemptionState.NONE - JobTracker.JobState.FAILURE -> ManageDonationsState.SubscriptionRedemptionState.FAILED - JobTracker.JobState.IGNORED -> ManageDonationsState.SubscriptionRedemptionState.NONE - } - }.orElse(ManageDonationsState.SubscriptionRedemptionState.NONE) + subscriptionRedemptionState = jobStateOptional.map(this::mapJobStateToRedemptionState).orElse(ManageDonationsState.RedemptionState.NONE) ) } } + disposables += SignalStore.donationsValues() + .observablePendingOneTimeDonation + .distinctUntilChanged() + .subscribeBy { pending -> + store.update { it.copy(pendingOneTimeDonation = pending.orNull()) } + } + disposables += levelUpdateOperationEdges.switchMapSingle { isProcessing -> if (isProcessing) { Single.just(ManageDonationsState.TransactionState.InTransaction) @@ -99,14 +100,14 @@ class ManageDonationsViewModel( }.subscribeBy( onNext = { transactionState -> store.update { - it.copy(transactionState = transactionState) + it.copy(subscriptionTransactionState = transactionState) } }, onError = { throwable -> Log.w(TAG, "Error retrieving subscription transaction state", throwable) store.update { - it.copy(transactionState = ManageDonationsState.TransactionState.NetworkFailure) + it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.NetworkFailure) } } ) @@ -121,6 +122,16 @@ class ManageDonationsViewModel( ) } + private fun mapJobStateToRedemptionState(jobState: JobTracker.JobState): ManageDonationsState.RedemptionState { + return when (jobState) { + JobTracker.JobState.PENDING -> ManageDonationsState.RedemptionState.IN_PROGRESS + JobTracker.JobState.RUNNING -> ManageDonationsState.RedemptionState.IN_PROGRESS + JobTracker.JobState.SUCCESS -> ManageDonationsState.RedemptionState.NONE + JobTracker.JobState.FAILURE -> ManageDonationsState.RedemptionState.FAILED + JobTracker.JobState.IGNORED -> ManageDonationsState.RedemptionState.NONE + } + } + class Factory( private val subscriptionsRepository: MonthlyDonationRepository ) : ViewModelProvider.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt new file mode 100644 index 0000000000..19377dcef1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.manage + +import android.widget.ProgressBar +import android.widget.TextView +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.PendingOneTimeDonationSerializer.toFiatMoney +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation +import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel + +/** + * Holds state information about pending one-time donations. + */ +object OneTimeDonationPreference { + + fun register(adapter: MappingAdapter) { + adapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, MySupportPreferenceBinding::inflate)) + } + + class Model( + val pendingOneTimeDonation: PendingOneTimeDonation, + val onPendingClick: (FiatMoney) -> Unit + ) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean { + return this.pendingOneTimeDonation == newItem.pendingOneTimeDonation + } + } + + class ViewHolder(binding: MySupportPreferenceBinding) : BindingViewHolder(binding) { + + val badge: BadgeImageView = binding.mySupportBadge + val title: TextView = binding.mySupportTitle + val expiry: TextView = binding.mySupportExpiry + val progress: ProgressBar = binding.mySupportProgress + + override fun bind(model: Model) { + badge.setBadge(Badges.fromDatabaseBadge(model.pendingOneTimeDonation.badge!!)) + title.text = context.getString( + R.string.OneTimeDonationPreference__one_time_s, + FiatMoneyUtil.format(context.resources, model.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + ) + + expiry.text = getPendingSubtitle(model.pendingOneTimeDonation.paymentMethodType) + + if (model.pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) { + itemView.setOnClickListener { model.onPendingClick(model.pendingOneTimeDonation.amount.toFiatMoney()) } + } + } + + private fun getPendingSubtitle(paymentMethodType: PendingOneTimeDonation.PaymentMethodType): String { + return when (paymentMethodType) { + PendingOneTimeDonation.PaymentMethodType.CARD -> context.getString(R.string.OneTimeDonationPreference__donation_processing) + PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT -> context.getString(R.string.OneTimeDonationPreference__donation_pending) + PendingOneTimeDonation.PaymentMethodType.PAYPAL -> context.getString(R.string.OneTimeDonationPreference__donation_processing) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/SubscriptionRedemptionJobWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/SubscriptionRedemptionJobWatcher.kt deleted file mode 100644 index 011b766250..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/SubscriptionRedemptionJobWatcher.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.manage - -import io.reactivex.rxjava3.core.Observable -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.jobmanager.JobTracker -import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob -import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob -import org.thoughtcrime.securesms.keyvalue.SignalStore -import java.util.Optional -import java.util.concurrent.TimeUnit - -/** - * Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions. - */ -object SubscriptionRedemptionJobWatcher { - fun watch(): Observable> = Observable.interval(0, 5, TimeUnit.SECONDS).map { - val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { - it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue == DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE - } - - val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { - it.factoryKey == SubscriptionReceiptRequestResponseJob.KEY && it.parameters.queue == DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE - } - - val jobState: JobTracker.JobState? = redemptionJobState ?: receiptJobState - - if (jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) { - Optional.of(JobTracker.JobState.FAILURE) - } else { - Optional.ofNullable(jobState) - } - }.distinctUntilChanged() -} 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 3eca549dca..d605eb156c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -38,7 +38,11 @@ public class DonationReceiptRedemptionJob extends BaseJob { private static final long NO_ID = -1L; public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption"; + public static final String ONE_TIME_QUEUE = "BoostReceiptRedemption"; public static final String KEY = "DonationReceiptRedemptionJob"; + + private static final String LONG_RUNNING_QUEUE_SUFFIX = "__LONG_RUNNING"; + public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation"; public static final String INPUT_KEEP_ALIVE_409 = "data.keep.alive.409"; public static final String DATA_ERROR_SOURCE = "data.error.source"; @@ -63,7 +67,7 @@ public class DonationReceiptRedemptionJob extends BaseJob { new Job.Parameters .Builder() .addConstraint(NetworkConstraint.KEY) - .setQueue(SUBSCRIPTION_QUEUE) + .setQueue(SUBSCRIPTION_QUEUE + (isLongRunningDonationPaymentType ? LONG_RUNNING_QUEUE_SUFFIX : "")) .setMaxAttempts(Parameters.UNLIMITED) .setMaxInstancesForQueue(1) .setLifespan(TimeUnit.DAYS.toMillis(1)) @@ -80,7 +84,7 @@ public class DonationReceiptRedemptionJob extends BaseJob { new Job.Parameters .Builder() .addConstraint(NetworkConstraint.KEY) - .setQueue("BoostReceiptRedemption") + .setQueue(ONE_TIME_QUEUE + (isLongRunningDonationPaymentType ? LONG_RUNNING_QUEUE_SUFFIX : "")) .setMaxAttempts(Parameters.UNLIMITED) .setLifespan(TimeUnit.DAYS.toMillis(1)) .build()); @@ -154,6 +158,8 @@ public class DonationReceiptRedemptionJob extends BaseJob { MultiDeviceSubscriptionSyncRequestJob.enqueue(); } else if (giftMessageId != NO_ID) { SignalDatabase.messages().markGiftRedemptionFailed(giftMessageId); + } else { + SignalStore.donationsValues().setPendingOneTimeDonation(null); } } @@ -228,6 +234,10 @@ public class DonationReceiptRedemptionJob extends BaseJob { MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(markedMessageInfo.getSyncMessageId())); } } + + if (isForOneTimeDonation()) { + SignalStore.donationsValues().setPendingOneTimeDonation(null); + } } private @Nullable ReceiptCredentialPresentation getPresentation() throws InvalidInputException, NoSuchMessageException { @@ -287,6 +297,10 @@ public class DonationReceiptRedemptionJob extends BaseJob { return Objects.equals(getParameters().getQueue(), SUBSCRIPTION_QUEUE); } + private boolean isForOneTimeDonation() { + return Objects.equals(getParameters().getQueue(), ONE_TIME_QUEUE) && giftMessageId == NO_ID; + } + private void enqueueDonationComplete(long receiptLevel) { if (errorSource == DonationErrorSource.GIFT || errorSource == DonationErrorSource.GIFT_REDEMPTION) { Log.i(TAG, "Skipping donation complete sheet for GIFT related redemption."); 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 963fc724de..d9b151fcd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -14,8 +14,10 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext import org.signal.libsignal.zkgroup.receipts.ReceiptSerial import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.PendingOneTimeDonationSerializer.isExpired import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.payments.currency.CurrencyUtil @@ -29,6 +31,7 @@ import org.whispersystems.signalservice.internal.util.JsonUtil import java.security.SecureRandom import java.util.Currency import java.util.Locale +import java.util.Optional import java.util.concurrent.TimeUnit internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { @@ -115,6 +118,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign * Popped from whenever we enter the conversation list. */ private const val DONATION_COMPLETE_QUEUE = "donation.complete.queue" + + /** + * The current one-time donation we are processing, if we are doing so. This is used for showing + * the donation processing / donation pending state in the ManageDonationsFragment. + */ + private const val PENDING_ONE_TIME_DONATION = "pending.one.time.donation" } override fun onFirstEverAppLaunch() = Unit @@ -142,6 +151,14 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign private val oneTimeCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) } val observableOneTimeCurrency: Observable by lazy { oneTimeCurrencyPublisher } + private var _pendingOneTimeDonation: PendingOneTimeDonation? by protoValue(PENDING_ONE_TIME_DONATION, PendingOneTimeDonation.ADAPTER) + private val pendingOneTimeDonationPublisher: Subject> by lazy { BehaviorSubject.createDefault(Optional.ofNullable(_pendingOneTimeDonation)) } + val observablePendingOneTimeDonation: Observable> by lazy { + pendingOneTimeDonationPublisher.map { optionalPendingOneTimeDonation -> + optionalPendingOneTimeDonation.filter { !it.isExpired } + } + } + fun getSubscriptionCurrency(): Currency { val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null) val currency: Currency? = if (currencyCode == null) { @@ -501,6 +518,13 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } + fun getPendingOneTimeDonation(): PendingOneTimeDonation? = _pendingOneTimeDonation.takeUnless { it?.isExpired == true } + + fun setPendingOneTimeDonation(pendingOneTimeDonation: PendingOneTimeDonation?) { + this._pendingOneTimeDonation = pendingOneTimeDonation + pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation)) + } + private fun generateRequestCredential(): ReceiptCredentialRequestContext { Log.d(TAG, "Generating request credentials context for token redemption...", true) val secureRandom = SecureRandom() diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt index 0a49e3d592..99d586a802 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.keyvalue +import com.squareup.wire.ProtoAdapter import org.signal.core.util.LongSerializer import kotlin.reflect.KProperty @@ -31,6 +32,10 @@ internal fun SignalStoreValues.enumValue(key: String, default: T, ser return KeyValueEnumValue(key, default, serializer, this.store) } +internal fun SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter): SignalStoreValueDelegate { + return KeyValueProtoValue(key, adapter, this.store) +} + /** * Kotlin delegate that serves as a base for all other value types. This allows us to only expose this sealed * class to callers and protect the individual implementations as private behind the various extension functions. @@ -109,6 +114,28 @@ private class BlobValue(private val key: String, private val default: ByteArray, } } +private class KeyValueProtoValue( + private val key: String, + private val adapter: ProtoAdapter, + store: KeyValueStore +) : SignalStoreValueDelegate(store) { + override fun getValue(values: KeyValueStore): M? { + return if (values.containsKey(key)) { + adapter.decode(values.getBlob(key, null)) + } else { + null + } + } + + override fun setValue(values: KeyValueStore, value: M?) { + if (value != null) { + values.beginWrite().putBlob(key, adapter.encode(value)).apply() + } else { + values.beginWrite().remove(key).apply() + } + } +} + private class KeyValueEnumValue(private val key: String, private val default: T, private val serializer: LongSerializer, store: KeyValueStore) : SignalStoreValueDelegate(store) { override fun getValue(values: KeyValueStore): T { return if (values.containsKey(key)) { 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 b4004d0736..dc2b1eb094 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/FiatMoneyUtil.java @@ -1,5 +1,4 @@ package org.thoughtcrime.securesms.payments; - import android.content.res.Resources; import androidx.annotation.NonNull; diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 36db7fa528..b32c599e0a 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -273,6 +273,31 @@ message SessionSwitchoverEvent { string e164 = 1; } +message DecimalValue { + uint32 scale = 1; + uint32 precision = 2; + bytes value = 3; +} + +message FiatValue { + string currencyCode = 1; + DecimalValue amount = 2; + uint64 timestamp = 3; +} + +message PendingOneTimeDonation { + enum PaymentMethodType { + CARD = 0; + SEPA_DEBIT = 1; + PAYPAL = 2; + } + + PaymentMethodType paymentMethodType = 1; + FiatValue amount = 2; + BadgeList.Badge badge = 3; + int64 timestamp = 4; +} + message DonationCompletedQueue { message DonationCompleted { int64 level = 1; diff --git a/app/src/main/res/layout/my_support_preference.xml b/app/src/main/res/layout/my_support_preference.xml index f39e3d544a..d3e74696c0 100644 --- a/app/src/main/res/layout/my_support_preference.xml +++ b/app/src/main/res/layout/my_support_preference.xml @@ -5,8 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/active_subscription_gutter_start" - android:layout_marginEnd="@dimen/dsl_settings_gutter" - tools:viewBindingIgnore="true"> + android:layout_marginEnd="@dimen/dsl_settings_gutter"> Donate %1$s/month + + + One time %1$s + + Donation pending + + Donation processing + Block and leave %1$s? Block %1$s? diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 596dd4448e..c06c444777 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -71,12 +71,17 @@ class StripeApi( return Single.fromCallable { val paymentMethodId = createPaymentMethodAndParseId(paymentSource) - val parameters = mapOf( + val parameters = mutableMapOf( "client_secret" to setupIntent.intentClientSecret, "payment_method" to paymentMethodId, "return_url" to RETURN_URL_3DS ) + if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) { + parameters["mandate_data[customer_acceptance][type]"] = "online" + parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" + } + val (nextActionUri, returnUri) = postForm("setup_intents/${setupIntent.intentId}/confirm", parameters).use { response -> getNextAction(response) }