Implement donations one-time pending state.

This commit is contained in:
Alex Hart
2023-10-16 12:58:20 -04:00
committed by Cody Henthorne
parent 57135ea2c6
commit 627c47b155
21 changed files with 429 additions and 101 deletions

View File

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

View File

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

View File

@@ -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<String> = emptyList(),
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation() != null,
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
) {
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)

View File

@@ -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<Boolean> = SubscriptionRedemptionJobWatcher.watch().map {
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true

View File

@@ -156,7 +156,7 @@ class PayPalPaymentInProgressViewModel(
gatewayRequest = request,
paymentIntentId = response.paymentId,
donationProcessor = DonationProcessor.PAYPAL,
isLongRunning = false
paymentSourceType = PaymentSourceType.PayPal
)
}
.subscribeOn(Schedulers.io())

View File

@@ -202,7 +202,7 @@ class StripePaymentInProgressViewModel(
gatewayRequest = request,
paymentIntentId = paymentIntent.intentId,
donationProcessor = DonationProcessor.STRIPE,
isLongRunning = paymentSource.type.isLongRunning
paymentSourceType = paymentSource.type
)
}
}.subscribeBy(

View File

@@ -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<Model>(itemView) {
class ViewHolder(binding: MySupportPreferenceBinding) : BindingViewHolder<Model, MySupportPreferenceBinding>(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))
}
}

View File

@@ -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<Optional<JobTracker.JobState>> = watch(RedemptionType.SUBSCRIPTION)
fun watchOneTimeRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.ONE_TIME)
private fun watch(redemptionType: RedemptionType): Observable<Optional<JobTracker.JobState>> = 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()
}

View File

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

View File

@@ -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<Subscription> = 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,

View File

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

View File

@@ -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<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean {
return this.pendingOneTimeDonation == newItem.pendingOneTimeDonation
}
}
class ViewHolder(binding: MySupportPreferenceBinding) : BindingViewHolder<Model, MySupportPreferenceBinding>(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)
}
}
}
}

View File

@@ -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<Optional<JobTracker.JobState>> = 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()
}

View File

@@ -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.");

View File

@@ -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<Currency> by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) }
val observableOneTimeCurrency: Observable<Currency> by lazy { oneTimeCurrencyPublisher }
private var _pendingOneTimeDonation: PendingOneTimeDonation? by protoValue(PENDING_ONE_TIME_DONATION, PendingOneTimeDonation.ADAPTER)
private val pendingOneTimeDonationPublisher: Subject<Optional<PendingOneTimeDonation>> by lazy { BehaviorSubject.createDefault(Optional.ofNullable(_pendingOneTimeDonation)) }
val observablePendingOneTimeDonation: Observable<Optional<PendingOneTimeDonation>> 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()

View File

@@ -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 <T : Any?> SignalStoreValues.enumValue(key: String, default: T, ser
return KeyValueEnumValue(key, default, serializer, this.store)
}
internal fun <M> SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter<M>): SignalStoreValueDelegate<M?> {
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<M>(
private val key: String,
private val adapter: ProtoAdapter<M>,
store: KeyValueStore
) : SignalStoreValueDelegate<M?>(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<T>(private val key: String, private val default: T, private val serializer: LongSerializer<T>, store: KeyValueStore) : SignalStoreValueDelegate<T>(store) {
override fun getValue(values: KeyValueStore): T {
return if (values.containsKey(key)) {

View File

@@ -1,5 +1,4 @@
package org.thoughtcrime.securesms.payments;
import android.content.res.Resources;
import androidx.annotation.NonNull;

View File

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

View File

@@ -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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View File

@@ -151,6 +151,14 @@
<!-- Button label to confirm credit card input and proceed with subscription payment -->
<string name="CreditCardFragment__donate_s_month">Donate %1$s/month</string>
<!-- OneTimeDonationPreference -->
<!-- Preference title with placeholder for amount. -->
<string name="OneTimeDonationPreference__one_time_s">One time %1$s</string>
<!-- Preference subtitle when donation is pending -->
<string name="OneTimeDonationPreference__donation_pending">Donation pending</string>
<!-- Preference subtitle when donation is processing -->
<string name="OneTimeDonationPreference__donation_processing">Donation processing</string>
<!-- BlockUnblockDialog -->
<string name="BlockUnblockDialog_block_and_leave_s">Block and leave %1$s?</string>
<string name="BlockUnblockDialog_block_s">Block %1$s?</string>

View File

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