Implement underpinnings of SEPA debit transfer support for donations.

This commit is contained in:
Alex Hart
2023-10-04 15:13:34 -04:00
committed by Nicholas Tinsley
parent 3dfd1c98ba
commit 15700b85cb
39 changed files with 1295 additions and 127 deletions

View File

@@ -83,7 +83,7 @@ class GiftFlowConfirmationFragment :
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.GIFT)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
@@ -106,6 +106,7 @@ class GiftFlowConfirmationFragment :
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
with(viewModel.snapshot) {
GatewayRequest(
uiSessionKey = viewModel.uiSessionKey,
donateToSignalType = DonateToSignalType.GIFT,
badge = giftBadge!!,
label = getString(R.string.preferences__one_time),
@@ -262,6 +263,10 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
val mainActivityIntent = MainActivity.clearTop(requireContext())

View File

@@ -39,6 +39,7 @@ class GiftFlowViewModel(
val state: Flowable<GiftFlowState> = store.stateFlowable
val events: Observable<DonationEvent> = eventPublisher
val snapshot: GiftFlowState get() = store.state
val uiSessionKey: Long = System.currentTimeMillis()
init {
refresh()

View File

@@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
}
private fun enqueueSubscriptionKeepAlive() {

View File

@@ -147,7 +147,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
@@ -186,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()

View File

@@ -111,7 +111,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
badgeRecipient: RecipientId,
additionalMessage: String?,
badgeLevel: Long,
donationProcessor: DonationProcessor
donationProcessor: DonationProcessor,
uiSessionKey: Long
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
@@ -131,9 +132,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
}
chain.enqueue { _, jobState ->

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -46,7 +47,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
fun isGooglePayAvailable(): Completable {
@@ -251,6 +252,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
}
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
}
data class StatusAndPaymentMethodId(
val status: StripeIntentStatus,
val paymentMethod: String?

View File

@@ -106,7 +106,7 @@ class DonateToSignalFragment :
}
override fun bindAdapter(adapter: MappingAdapter) {
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -417,6 +417,10 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
}

View File

@@ -58,6 +58,7 @@ class DonateToSignalViewModel(
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
val uiSessionKey: Long = System.currentTimeMillis()
init {
initializeOneTimeDonationState(oneTimeDonationRepository)
@@ -178,6 +179,7 @@ class DonateToSignalViewModel(
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
val amount = getAmount(snapshot)
return GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = snapshot.donateToSignalType,
badge = snapshot.badge!!,
label = snapshot.badge!!.description,

View File

@@ -43,6 +43,7 @@ import java.util.Currency
class DonationCheckoutDelegate(
private val fragment: Fragment,
private val callback: Callback,
private val uiSessionKey: Long,
errorSource: DonationErrorSource,
vararg additionalSources: DonationErrorSource
) : DefaultLifecycleObserver {
@@ -65,7 +66,7 @@ class DonationCheckoutDelegate(
init {
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
ErrorHandler().attach(fragment, callback, errorSource, *additionalSources)
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
}
override fun onCreate(owner: LifecycleOwner) {
@@ -100,6 +101,7 @@ class DonationCheckoutDelegate(
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
}
} else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
@@ -154,6 +156,10 @@ class DonationCheckoutDelegate(
callback.navigateToCreditCardForm(gatewayResponse.request)
}
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse.request)
}
private fun registerGooglePayCallback() {
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
@@ -206,7 +212,7 @@ class DonationCheckoutDelegate(
private var errorDialog: DialogInterface? = null
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
this.fragment = fragment
this.userCancelledFlowCallback = userCancelledFlowCallback
@@ -218,6 +224,8 @@ class DonationCheckoutDelegate(
additionalSources.forEach { source ->
disposables += registerErrorSource(source)
}
disposables += registerUiSession(uiSessionKey)
}
override fun onDestroy(owner: LifecycleOwner) {
@@ -234,6 +242,14 @@ class DonationCheckoutDelegate(
}
}
private fun registerUiSession(uiSessionKey: Long): Disposable {
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
showErrorDialog(it)
}
}
private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
@@ -281,6 +297,7 @@ class DonationCheckoutDelegate(
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
}

View File

@@ -54,7 +54,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, null, errorSource)
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!

View File

@@ -12,6 +12,7 @@ import java.util.Currency
@Parcelize
data class GatewayRequest(
val uiSessionKey: Long,
val donateToSignalType: DonateToSignalType,
val badge: Badge,
val label: String,

View File

@@ -9,13 +9,15 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
enum class Gateway {
GOOGLE_PAY,
PAYPAL,
CREDIT_CARD;
CREDIT_CARD,
SEPA_DEBIT;
fun toPaymentSourceType(): PaymentSourceType {
return when (this) {
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
}
}
}

View File

@@ -115,6 +115,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
if (state.isSEPADebitAvailable) {
space(8.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
space(16.dp)
}
}

View File

@@ -17,6 +17,7 @@ class GatewaySelectorRepository(
when (it) {
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
else -> listOf()
}
}.flatten().toSet()

View File

@@ -7,5 +7,6 @@ data class GatewaySelectorState(
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false
)

View File

@@ -24,7 +24,8 @@ class GatewaySelectorViewModel(
badge = args.request.badge,
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType)
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
)
)
private val disposables = CompositeDisposable()
@@ -41,7 +42,8 @@ class GatewaySelectorViewModel(
loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL)
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
)
}
}

View File

@@ -43,7 +43,6 @@ class PayPalPaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
override fun onCleared() {
store.dispose()
disposables.clear()
@@ -82,7 +81,7 @@ class PayPalPaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -157,7 +156,8 @@ class PayPalPaymentInProgressViewModel(
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL
donationProcessor = DonationProcessor.PAYPAL,
uiSessionKey = request.uiSessionKey
)
}
.subscribeOn(Schedulers.io())
@@ -190,7 +190,7 @@ class PayPalPaymentInProgressViewModel(
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)

View File

@@ -44,8 +44,7 @@ class StripePaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
private var paymentData: PaymentData? = null
private var cardData: StripeApi.CardData? = null
private var stripePaymentData: StripePaymentData? = null
override fun onCleared() {
disposables.clear()
@@ -87,19 +86,18 @@ class StripePaymentInProgressViewModel(
}
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
val paymentData = this.paymentData
val cardData = this.cardData
return when {
paymentData == null && cardData == null -> error("No payment provider available.")
paymentData != null && cardData != null -> error("Too many providers available")
paymentData != null -> PaymentSourceProvider(
return when (val data = stripePaymentData) {
is StripePaymentData.GooglePay -> PaymentSourceProvider(
PaymentSourceType.Stripe.GooglePay,
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() }
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
)
cardData != null -> PaymentSourceProvider(
is StripePaymentData.CreditCard -> PaymentSourceProvider(
PaymentSourceType.Stripe.CreditCard,
stripeRepository.createCreditCardPaymentSource(errorSource, cardData).doAfterTerminate { clearPaymentInformation() }
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
PaymentSourceType.Stripe.SEPADebit,
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
)
else -> error("This should never happen.")
}
@@ -107,23 +105,26 @@ class StripePaymentInProgressViewModel(
fun providePaymentData(paymentData: PaymentData) {
requireNoPaymentInformation()
this.paymentData = paymentData
this.stripePaymentData = StripePaymentData.GooglePay(paymentData)
}
fun provideCardData(cardData: StripeApi.CardData) {
requireNoPaymentInformation()
this.cardData = cardData
this.stripePaymentData = StripePaymentData.CreditCard(cardData)
}
fun provideSEPADebitData(bankData: StripeApi.SEPADebitData) {
requireNoPaymentInformation()
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
}
private fun requireNoPaymentInformation() {
require(paymentData == null)
require(cardData == null)
require(stripePaymentData == null)
}
private fun clearPaymentInformation() {
Log.d(TAG, "Cleared payment information.", true)
paymentData = null
cardData = null
stripePaymentData = null
}
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
@@ -132,7 +133,7 @@ class StripePaymentInProgressViewModel(
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -201,7 +202,8 @@ class StripePaymentInProgressViewModel(
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE
donationProcessor = DonationProcessor.STRIPE,
uiSessionKey = request.uiSessionKey
)
}
}.subscribeBy(
@@ -246,7 +248,7 @@ class StripePaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -271,6 +273,12 @@ class StripePaymentInProgressViewModel(
val paymentSource: Single<StripeApi.PaymentSource>
)
private sealed interface StripePaymentData {
class GooglePay(val paymentData: PaymentData) : StripePaymentData
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
}
class Factory(
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),

View File

@@ -0,0 +1,302 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
*/
class BankTransferDetailsFragment : ComposeFragment() {
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
@Composable
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
)
}
}
BankTransferDetailsContent(
state = state,
onNavigationClick = this::onNavigationClick,
onNameChanged = viewModel::onNameChanged,
onIBANChanged = viewModel::onIBANChanged,
onEmailChanged = viewModel::onEmailChanged,
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
donateLabel = donateLabel
)
}
private fun onNavigationClick() {
findNavController().popBackStack()
}
private fun onFindAccountNumbersClicked() {
// TODO [sepa] -- FindAccountNumbersBottomSheet
}
private fun onDonateClick() {
stripePaymentViewModel.provideSEPADebitData(viewModel.state.value.asSEPADebitData())
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
)
)
}
}
@Preview
@Composable
private fun BankTransferDetailsContentPreview() {
SignalTheme {
BankTransferDetailsContent(
state = BankTransferDetailsState(
name = "Miles Morales"
),
onNavigationClick = {},
onNameChanged = {},
onIBANChanged = {},
onEmailChanged = {},
onFindAccountNumbersClicked = {},
onDonateClick = {},
onIBANFocusChanged = {},
donateLabel = "Donate $5/month"
)
}
}
@Composable
private fun BankTransferDetailsContent(
state: BankTransferDetailsState,
onNavigationClick: () -> Unit,
onNameChanged: (String) -> Unit,
onIBANChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFindAccountNumbersClicked: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
donateLabel: String
) {
Scaffolds.Settings(
title = "Bank transfer",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(it)
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
) {
item {
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
)
}
item {
TextField(
value = state.iban,
onValueChange = onIBANChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__iban))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
isError = state.ibanValidity.isError,
supportingText = {
if (state.ibanValidity.isError) {
Text(
text = when (state.ibanValidity) {
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
else -> error("Unexpected error.")
}
)
}
},
visualTransformation = IBANVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.focusRequester(focusRequester)
)
}
item {
TextField(
value = state.name,
onValueChange = onNameChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__name_on_bank_account))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = onFindAccountNumbersClicked
) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
}
}
}
}
Buttons.LargeTonal(
enabled = state.canProceed,
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = donateLabel)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import org.signal.donations.StripeApi
data class BankTransferDetailsState(
val name: String = "",
val iban: String = "",
val email: String = "",
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
) {
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
fun asSEPADebitData(): StripeApi.SEPADebitData {
return StripeApi.SEPADebitData(
iban = iban,
name = name,
email = email
)
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class BankTransferDetailsViewModel : ViewModel() {
companion object {
private const val IBAN_MAX_CHARACTER_COUNT = 34
}
private val internalState = mutableStateOf(BankTransferDetailsState())
val state: State<BankTransferDetailsState> = internalState
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name
)
}
fun onIBANFocusChanged(isFocused: Boolean) {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
}
fun onIBANChanged(iban: String) {
internalState.value = internalState.value.copy(
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
ibanValidity = IBANValidator.validate(internalState.value.iban, true)
)
}
fun onEmailChanged(email: String) {
internalState.value = internalState.value.copy(
email = email
)
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import java.math.BigInteger
object IBANValidator {
private val countryCodeToLength: Map<String, Int> by lazy {
mapOf(
"AL" to 28,
"AD" to 24,
"AT" to 20,
"AZ" to 28,
"BH" to 22,
"BY" to 28,
"BE" to 16,
"BA" to 20,
"BR" to 29,
"BG" to 22,
"CR" to 22,
"HR" to 21,
"CY" to 28,
"CZ" to 24,
"DK" to 18,
"DO" to 28,
"TL" to 23,
"EG" to 29,
"SV" to 28,
"EE" to 20,
"FO" to 18,
"FI" to 18,
"FR" to 27,
"GE" to 22,
"DE" to 22,
"GI" to 23,
"GR" to 27,
"GL" to 18,
"GT" to 28,
"HU" to 28,
"IS" to 26,
"IQ" to 23,
"IE" to 22,
"IL" to 23,
"IT" to 27,
"JO" to 30,
"KZ" to 20,
"XK" to 20,
"KW" to 30,
"LV" to 21,
"LB" to 28,
"LY" to 25,
"LI" to 21,
"LT" to 20,
"LU" to 20,
"MT" to 31,
"MR" to 27,
"MU" to 30,
"MC" to 27,
"MD" to 24,
"ME" to 22,
"NL" to 18,
"MK" to 19,
"NO" to 15,
"PK" to 24,
"PS" to 29,
"PL" to 28,
"PT" to 25,
"QA" to 29,
"RO" to 24,
"RU" to 33,
"LC" to 32,
"SM" to 27,
"ST" to 25,
"SA" to 24,
"RS" to 22,
"SC" to 31,
"SK" to 24,
"SI" to 19,
"ES" to 24,
"SD" to 18,
"SE" to 24,
"CH" to 21,
"TN" to 24,
"TR" to 26,
"UA" to 29,
"AE" to 23,
"GB" to 22,
"VA" to 22,
"VG" to 24
)
}
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.isEmpty()) {
return Validity.POTENTIALLY_VALID
}
val lengthValidity = validateLength(iban, isIBANFieldFocused)
if (lengthValidity != Validity.COMPLETELY_VALID) {
return lengthValidity
}
val countryAndCheck = iban.take(4)
val rearranged = iban.drop(4) + countryAndCheck
val expanded = rearranged.map {
if (it.isLetter()) {
(it - 'A') + 10
} else if (it.isDigit()) {
it.digitToInt()
} else {
return Validity.INVALID_CHARACTERS
}
}.joinToString("")
val bigInteger = BigInteger(expanded)
if (bigInteger.mod(BigInteger.valueOf(97L)) == BigInteger.ONE) {
return Validity.COMPLETELY_VALID
}
return Validity.INVALID_MOD_97
}
private fun validateLength(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.length < 2) {
return if (isIBANFieldFocused) {
Validity.POTENTIALLY_VALID
} else {
Validity.TOO_SHORT
}
}
val countryCode = iban.take(2)
val requiredLength = countryCodeToLength[countryCode] ?: -1
if (requiredLength == -1) {
return Validity.INVALID_COUNTRY
}
if (requiredLength > iban.length) {
return if (isIBANFieldFocused) Validity.POTENTIALLY_VALID else Validity.TOO_SHORT
}
if (requiredLength < iban.length) {
return Validity.TOO_LONG
}
return Validity.COMPLETELY_VALID
}
enum class Validity(val isError: Boolean) {
TOO_SHORT(true),
TOO_LONG(true),
INVALID_COUNTRY(true),
INVALID_CHARACTERS(true),
INVALID_MOD_97(true),
POTENTIALLY_VALID(false),
COMPLETELY_VALID(false)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
/**
* Transforms the given input string to an IBAN representative format:
*
* AB1234567890 becomes AB12 3456 7890
*/
object IBANVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var output = ""
for (i in text.take(34).indices) {
output += text[i]
if (i % 4 == 3) {
output += " "
}
}
return TransformedText(
text = AnnotatedString(output),
offsetMapping = IBANOffsetMapping
)
}
private object IBANOffsetMapping : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + (offset / 4)
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / 4)
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Displays Bank Transfer legal mandate users must agree to to move forward.
*/
class BankTransferMandateFragment : ComposeFragment() {
private val args: BankTransferMandateFragmentArgs by navArgs()
private val viewModel: BankTransferMandateViewModel by viewModels()
@Composable
override fun FragmentContent() {
val mandate by viewModel.mandate
BankTransferScreen(
bankMandate = mandate,
onNavigationClick = this::onNavigationClick,
onContinueClick = this::onContinueClick
)
}
private fun onNavigationClick() {
findNavController().popBackStack()
}
private fun onContinueClick() {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
)
}
}
@Preview
@Composable
fun BankTransferScreenPreview() {
SignalTheme {
BankTransferScreen(
bankMandate = "Test ".repeat(500),
onNavigationClick = {},
onContinueClick = {}
)
}
}
@Composable
fun BankTransferScreen(
bankMandate: String,
onNavigationClick: () -> Unit,
onContinueClick: () -> Unit
) {
Scaffolds.Settings(
title = "",
onNavigationClick = onNavigationClick,
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
LazyColumn(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 64.dp)
) {
item {
Image(
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
contentScale = ContentScale.Inside,
contentDescription = null,
modifier = Modifier
.size(72.dp)
.background(
SignalTheme.colors.colorSurface2,
CircleShape
)
)
}
item {
Text(
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
)
}
item {
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
)
}
item {
Dividers.Default()
}
item {
Text(
text = bankMandate,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
)
}
}
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.padding(top = 16.dp, bottom = 46.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
}
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
class BankTransferMandateRepository {
fun getMandate(): Single<String> {
return Single
.fromCallable { ApplicationDependencies.getDonationsService().getBankMandate(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.map { it.mandate }
.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
class BankTransferMandateViewModel(
repository: BankTransferMandateRepository = BankTransferMandateRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val internalMandate = mutableStateOf("")
val mandate: State<String> = internalMandate
init {
disposables += repository.getMandate()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = { internalMandate.value = it },
onError = { internalMandate.value = "Failed to load mandate." }
)
}
}

View File

@@ -113,11 +113,40 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
source to PublishSubject.create()
}
private val donationErrorsSubjectUiSessionMap: MutableMap<Long, Subject<DonationError>> = mutableMapOf()
@JvmStatic
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
return donationErrorSubjectSourceMap[donationErrorSource]!!
}
fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable<DonationError> {
val subject: Subject<DonationError> = donationErrorsSubjectUiSessionMap[uiSessionKey] ?: PublishSubject.create()
donationErrorsSubjectUiSessionMap[uiSessionKey] = subject
return subject
}
@JvmStatic
fun routeBackgroundError(context: Context, uiSessionKey: Long, error: DonationError) {
if (error.source == DonationErrorSource.GIFT_REDEMPTION) {
routeDonationError(context, error)
return
}
val subject: Subject<DonationError>? = donationErrorsSubjectUiSessionMap[uiSessionKey]
when {
subject != null && subject.hasObservers() -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
subject.onNext(error)
}
else -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
DonationErrorNotifications.displayErrorNotification(context, error)
}
}
}
/**
* Route a given donation error, which will either pipe it out to an appropriate subject
* or, if the subject has no observers, post it as a notification.

View File

@@ -49,6 +49,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String DATA_ERROR_SOURCE = "data.error.source";
private static final String DATA_BADGE_LEVEL = "data.badge.level";
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private ReceiptCredentialRequestContext requestContext;
@@ -56,8 +57,9 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private final String paymentIntentId;
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private final long uiSessionKey;
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor) {
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor, long uiSessionKey) {
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
@@ -70,13 +72,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
paymentIntentId,
donationErrorSource,
badgeLevel,
donationProcessor
donationProcessor,
uiSessionKey
);
}
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId, @NonNull DonationProcessor donationProcessor) {
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -91,9 +97,10 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@NonNull RecipientId recipientId,
@Nullable String additionalMessage,
long badgeLevel,
@NonNull DonationProcessor donationProcessor)
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
@@ -107,7 +114,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel,
@NonNull DonationProcessor donationProcessor)
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
{
super(parameters);
this.requestContext = requestContext;
@@ -115,6 +123,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
}
@Override
@@ -122,7 +131,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
JsonJobData.Builder builder = new JsonJobData.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode());
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@@ -168,7 +178,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
if (!isCredentialValid(receiptCredential)) {
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
throw new IOException("Could not validate receipt credential");
}
@@ -183,7 +193,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
}
}
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) {
case 204:
@@ -191,15 +201,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
throw new Exception(applicationException);
case 402:
Log.w(TAG, "User payment failed.", applicationException, true);
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(donationErrorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource));
throw new Exception(applicationException);
case 409:
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
throw new Exception(applicationException);
default:
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
@@ -272,15 +282,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View File

@@ -43,16 +43,19 @@ public class DonationReceiptRedemptionJob extends BaseJob {
public static final String DATA_ERROR_SOURCE = "data.error.source";
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary";
public static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private final long giftMessageId;
private final boolean makePrimary;
private final DonationErrorSource errorSource;
private final long uiSessionKey;
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
errorSource,
uiSessionKey,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -63,11 +66,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.build());
}
public static DonationReceiptRedemptionJob createJobForBoost() {
public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
DonationErrorSource.BOOST,
uiSessionKey,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -78,7 +82,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
public static JobManager.Chain createJobChainForKeepAlive() {
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE);
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L);
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -93,6 +97,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
messageId,
primary,
DonationErrorSource.GIFT_REDEMPTION,
-1L,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -110,11 +115,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.then(multiDeviceProfileContentUpdateJob);
}
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
super(parameters);
this.giftMessageId = giftMessageId;
this.makePrimary = primary;
this.errorSource = errorSource;
this.uiSessionKey = uiSessionKey;
}
@Override
@@ -123,6 +129,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.putString(DATA_ERROR_SOURCE, errorSource.serialize())
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.serialize();
}
@@ -185,7 +192,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
throw new RetryableException();
} else {
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(errorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource));
throw new IOException(response.getApplicationError().get());
}
} else if (response.getExecutionError().isPresent()) {
@@ -288,8 +295,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, parameters);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, parameters);
}
}
}

View File

@@ -136,7 +136,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
Log.i(TAG, "Subscription end of period is after the conversion end of period. Storing it, generating a credential, and enqueuing the continuation job chain.", true);
SignalStore.donationsValues().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
SignalStore.donationsValues().refreshSubscriptionRequestCredential();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) {
if (SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
Log.i(TAG, "We have not started a redemption, but do not have a request credential. Possible that the subscription changed.", true);
@@ -144,7 +144,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
}
Log.i(TAG, "We have a request credential and have not yet turned it into a redeemable token.", true);
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()) {
if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) {
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);

View File

@@ -47,13 +47,15 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
private static final String DATA_IS_FOR_KEEP_ALIVE = "data.is.for.keep.alive";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
public static final Object MUTEX = new Object();
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive) {
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey) {
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
@@ -64,18 +66,19 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
subscriberId,
isForKeepAlive
isForKeepAlive,
uiSessionKey
);
}
public static JobManager.Chain createSubscriptionContinuationJobChain() {
return createSubscriptionContinuationJobChain(false);
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey) {
return createSubscriptionContinuationJobChain(false, uiSessionKey);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive) {
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey) {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource());
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -88,17 +91,20 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
@NonNull SubscriberId subscriberId,
boolean isForKeepAlive)
boolean isForKeepAlive,
long uiSessionKey)
{
super(parameters);
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
}
@Override
public @Nullable byte[] serialize() {
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive);
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
return builder.serialize();
}
@@ -189,7 +195,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get());
if (!isCredentialValid(subscription, receiptCredential)) {
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new IOException("Could not validate receipt credential");
}
@@ -215,7 +221,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
return activeSubscription.getResult().get();
} else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new IOException(activeSubscription.getApplicationError().get());
} else {
throw new RetryableException();
@@ -252,18 +258,18 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new Exception(response.getApplicationError().get());
case 402:
Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true);
throw new RetryableException();
case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new Exception(response.getApplicationError().get());
case 404:
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new Exception(response.getApplicationError().get());
case 409:
onAlreadyRedeemed(response);
@@ -321,7 +327,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
DonationError.routeDonationError(context, paymentSetupError);
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
} else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) {
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true);
@@ -359,10 +365,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
DonationError.routeDonationError(context, paymentSetupError);
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
} else {
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
DonationError.routeDonationError(context, new DonationError.PaymentSetupError.GenericError(
DonationError.routeBackgroundError(context, uiSessionKey, new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Got a failure status from the subscription object.")
));
@@ -378,7 +384,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
setOutputData(new JsonJobData.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).serialize());
} else {
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
throw new Exception(response.getApplicationError().get());
}
}
@@ -429,6 +435,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
@@ -441,7 +448,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive);
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey);
}
}
}