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

View file

@ -42,6 +42,9 @@
<action
android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment"
app:destination="@id/paypalPaymentInProgressFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_bankTransferMandateFragment"
app:destination="@id/bankTransferMandateFragment" />
</fragment>
@ -206,4 +209,32 @@
app:nullable="false" />
</dialog>
<fragment
android:id="@+id/bankTransferMandateFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
android:label="bank_transfer_mandate_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
app:destination="@id/bankTransferDetailsFragment" />
</fragment>
<fragment
android:id="@+id/bankTransferDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
android:label="bank_transfer_details_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferDetailsFragment_to_stripePaymentInProgressFragment"
app:destination="@id/stripePaymentInProgressFragment" />
</fragment>
</navigation>

View file

@ -62,6 +62,9 @@
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_paypalPaymentInProgressFragment"
app:destination="@id/paypalPaymentInProgressFragment" />
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_bankTransferMandateFragment"
app:destination="@id/bankTransferMandateFragment" />
</fragment>
<dialog
@ -186,4 +189,29 @@
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</dialog>
<fragment
android:id="@+id/bankTransferMandateFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
android:label="bank_transfer_mandate_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
app:destination="@id/bankTransferDetailsFragment" />
</fragment>
<fragment
android:id="@+id/bankTransferDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
android:label="bank_transfer_details_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</fragment>
</navigation>

View file

@ -5808,11 +5808,49 @@
<item quantity="one">Get a %1$s badge for %2$d day</item>
<item quantity="other">Get a %1$s badge for %2$d days</item>
</plurals>
<!-- Button label for paying with a bank transfer -->
<string name="GatewaySelectorBottomSheet__bank_transfer">Bank transfer</string>
<!-- Button label for paying with a credit card -->
<string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string>
<!-- Sheet summary when giving donating for a friend -->
<string name="GatewaySelectorBottomSheet__donate_for_a_friend">Donate for a friend</string>
<!-- BankTransferMandateFragment -->
<!-- Title of screen displaying the bank transfer mandate -->
<string name="BankTransferMandateFragment__bank_transfer">Bank Transfer</string>
<!-- Subtitle of screen displaying the bank transfer mandate, placeholder is 'Learn more' -->
<string name="BankTransferMandateFragment__stripe_processes_donations">Stripe processes donations made to Signal. Signal does not collect or store your personal information. %1$s</string>
<!-- Subtitle learn more of screen displaying bank transfer mandate -->
<string name="BankTransferMandateFragment__learn_more">Learn more</string>
<!-- Button label to continue with transfer -->
<string name="BankTransferMandateFragment__continue">Continue</string>
<!-- BankTransferDetailsFragment -->
<!-- Subtext explaining how email is used. Placeholder is 'Learn more' -->
<string name="BankTransferDetailsFragment__enter_your_bank_details">Enter your bank details and email address. Your email is used by Stripe to send you updates about your donation. %1$s</string>
<!-- Subtext learn more link text -->
<string name="BankTransferDetailsFragment__learn_more">Learn more</string>
<!-- Text field label for name on bank account -->
<string name="BankTransferDetailsFragment__name_on_bank_account">Name on bank account</string>
<!-- Text field label for IBAN -->
<string name="BankTransferDetailsFragment__iban">IBAN</string>
<!-- Text field label for email -->
<string name="BankTransferDetailsFragment__email">Email</string>
<!-- Text label for button to show user how to find their IBAN number -->
<string name="BankTransferDetailsFragment__find_account_numbers">Find account numbers</string>
<!-- Donate button label for monthly subscription -->
<string name="BankTransferDetailsFragment__donate_s_month">Donate %1$s/month</string>
<!-- Donate button label for one-time -->
<string name="BankTransferDetailsFragment__donate_s">Donate %1$s</string>
<!-- Error label for IBAN field when number is too short -->
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_short">IBAN number is too short</string>
<!-- Error label for IBAN field when number is too long -->
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_long">IBAN number is too long</string>
<!-- Error label for IBAN field when country is not supported -->
<string name="BankTransferDetailsFragment__iban_country_code_is_not_supported">IBAN country code is not supported</string>
<!-- Error label for IBAN field when number is invalid -->
<string name="BankTransferDetailsFragment__invalid_iban_nubmer">Invalid IBAN number</string>
<!-- StripePaymentInProgressFragment -->
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>

View file

@ -0,0 +1,66 @@
/*
* 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.junit.Assert.assertEquals
import org.junit.Test
class IBANValidatorTest {
companion object {
private const val VALID_IBAN = "GB82WEST12345698765432"
private const val INVALID_IBAN = "GB82WEST12335698765432"
private const val INVALID_COUNTRY = "US82WEST12335698765432"
}
@Test
fun `Given a blank IBAN, when I validate, then I expect POTENTIALLY_VALID`() {
val actual = IBANValidator.validate("", false)
assertEquals(IBANValidator.Validity.POTENTIALLY_VALID, actual)
}
@Test
fun `Given a valid IBAN, when I validate, then I expect COMPLETELY_VALID`() {
val actual = IBANValidator.validate(VALID_IBAN, false)
assertEquals(IBANValidator.Validity.COMPLETELY_VALID, actual)
}
@Test
fun `Given an invalid IBAN, when I validate, then I expect INVALID_MOD_97`() {
val actual = IBANValidator.validate(INVALID_IBAN, false)
assertEquals(IBANValidator.Validity.INVALID_MOD_97, actual)
}
@Test
fun `Given an invalid country, when I validate, then I expect INVALID_COUNTRY`() {
val actual = IBANValidator.validate(INVALID_COUNTRY, false)
assertEquals(IBANValidator.Validity.INVALID_COUNTRY, actual)
}
@Test
fun `Given too short and not focused, when I validate, then I expect TOO_SHORT`() {
val actual = IBANValidator.validate(VALID_IBAN.dropLast(5), false)
assertEquals(IBANValidator.Validity.TOO_SHORT, actual)
}
@Test
fun `Given too short and focused, when I validate, then I expect POTENTIALLY_VALID`() {
val actual = IBANValidator.validate(VALID_IBAN.dropLast(5), true)
assertEquals(IBANValidator.Validity.POTENTIALLY_VALID, actual)
}
@Test
fun `Given too long, when I validate, then I expect TOO_LONG`() {
val actual = IBANValidator.validate(VALID_IBAN + "A", false)
assertEquals(IBANValidator.Validity.TOO_LONG, actual)
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
class SEPADebitPaymentSource(
val sepaDebitData: StripeApi.SEPADebitData
) : StripeApi.PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.SEPADebit
override fun parameterize(): JSONObject = error("SEPA Debit does not support tokenization")
override fun getTokenId(): String = error("SEPA Debit does not support tokenization")
override fun email(): String? = null
}

View file

@ -27,7 +27,8 @@ class StripeApi(
private val configuration: Configuration,
private val paymentIntentFetcher: PaymentIntentFetcher,
private val setupIntentHelper: SetupIntentHelper,
private val okHttpClient: OkHttpClient
private val okHttpClient: OkHttpClient,
private val userAgent: String
) {
private val objectMapper = jsonMapper {
@ -119,6 +120,12 @@ class StripeApi(
"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][ip_address]"] = "0.0.0.0"
parameters["mandate_data[customer_acceptance][online][user_agent]"] = userAgent
}
val (nextActionUri, returnUri) = postForm("payment_intents/${paymentIntent.intentId}/confirm", parameters).use { response ->
getNextAction(response)
}
@ -198,6 +205,10 @@ class StripeApi(
}.subscribeOn(Schedulers.io())
}
fun createPaymentSourceFromSEPADebitData(sepaDebitData: SEPADebitData): Single<PaymentSource> {
return Single.just(SEPADebitPaymentSource(sepaDebitData))
}
@WorkerThread
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
val parameters: Map<String, String> = mutableMapOf(
@ -218,7 +229,13 @@ class StripeApi(
}
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
return createPaymentMethod(paymentSource).use { response ->
val paymentMethodResponse = if (paymentSource is SEPADebitPaymentSource) {
createPaymentMethodForSEPADebit(paymentSource)
} else {
createPaymentMethodForToken(paymentSource)
}
return paymentMethodResponse.use { response ->
val body = response.body()
if (body != null) {
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) }
@ -229,7 +246,18 @@ class StripeApi(
}
}
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
private fun createPaymentMethodForSEPADebit(paymentSource: SEPADebitPaymentSource): Response {
val parameters = mutableMapOf(
"type" to "sepa_debit",
"sepa_debit[iban]" to paymentSource.sepaDebitData.iban,
"billing_details[email]" to paymentSource.sepaDebitData.email,
"billing_details[name]" to paymentSource.sepaDebitData.name
)
return postForm("payment_methods", parameters)
}
private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response {
val tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf(
"card[token]" to tokenId,
@ -532,6 +560,13 @@ class StripeApi(
val cvc: Int
) : Parcelable
@Parcelize
data class SEPADebitData(
val iban: String,
val name: String,
val email: String
) : Parcelable
interface PaymentSource {
val type: PaymentSourceType
fun parameterize(): JSONObject

View file

@ -17,6 +17,7 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.push.BankMandate;
import org.whispersystems.signalservice.internal.push.DonationProcessor;
import org.whispersystems.signalservice.internal.push.DonationsConfiguration;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
@ -38,17 +39,18 @@ public class DonationsService {
private final PushServiceSocket pushServiceSocket;
private final AtomicReference<CacheEntry> donationsConfigurationCache = new AtomicReference<>(null);
private final AtomicReference<CacheEntry<DonationsConfiguration>> donationsConfigurationCache = new AtomicReference<>(null);
private final AtomicReference<CacheEntry<BankMandate>> sepaBankMandateCache = new AtomicReference<>(null);
private static class CacheEntry {
private final DonationsConfiguration donationsConfiguration;
private final long expiresAt;
private final Locale locale;
private static class CacheEntry<T> {
private final T cachedValue;
private final long expiresAt;
private final Locale locale;
private CacheEntry(DonationsConfiguration donationsConfiguration, long expiresAt, Locale locale) {
this.donationsConfiguration = donationsConfiguration;
this.expiresAt = expiresAt;
this.locale = locale;
private CacheEntry(T cachedValue, long expiresAt, Locale locale) {
this.cachedValue = cachedValue;
this.expiresAt = expiresAt;
this.locale = locale;
}
}
@ -107,22 +109,42 @@ public class DonationsService {
}
public ServiceResponse<DonationsConfiguration> getDonationsConfiguration(Locale locale) {
CacheEntry cacheEntryOutsideLock = donationsConfigurationCache.get();
return getCachedValue(
locale,
donationsConfigurationCache,
pushServiceSocket::getDonationsConfiguration
);
}
public ServiceResponse<BankMandate> getBankMandate(Locale locale) {
return getCachedValue(
locale,
sepaBankMandateCache,
l -> pushServiceSocket.getBankMandate(l, "SEPA_DEBIT")
);
}
private <T> ServiceResponse<T> getCachedValue(Locale locale,
AtomicReference<CacheEntry<T>> cachedValueReference,
CacheEntryValueProducer<T> cacheEntryValueProducer
)
{
CacheEntry<T> cacheEntryOutsideLock = cachedValueReference.get();
if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) {
synchronized (this) {
CacheEntry cacheEntryInLock = donationsConfigurationCache.get();
CacheEntry<T> cacheEntryInLock = cachedValueReference.get();
if (isNewCacheEntryRequired(cacheEntryInLock, locale)) {
return wrapInServiceResponse(() -> {
DonationsConfiguration donationsConfiguration = pushServiceSocket.getDonationsConfiguration(locale);
donationsConfigurationCache.set(new CacheEntry(donationsConfiguration, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale));
return new Pair<>(donationsConfiguration, 200);
T value = cacheEntryValueProducer.produce(locale);
cachedValueReference.set(new CacheEntry<>(value, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale));
return new Pair<>(value, 200);
});
} else {
return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.donationsConfiguration, 200));
return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.cachedValue, 200));
}
}
} else {
return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.donationsConfiguration, 200));
return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.cachedValue, 200));
}
}
@ -219,13 +241,13 @@ public class DonationsService {
* 400 - request error
* 409 - level requires a valid currency/amount combination that does not match
*
* @param locale User locale for proper language presentation
* @param currencyCode 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param returnUrl The 'return' url after a successful login and confirmation
* @param cancelUrl The 'cancel' url for a cancelled confirmation
* @return Wrapped response with either an error code or a payment id and approval URL
* @param locale User locale for proper language presentation
* @param currencyCode 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param returnUrl The 'return' url after a successful login and confirmation
* @param cancelUrl The 'cancel' url for a cancelled confirmation
* @return Wrapped response with either an error code or a payment id and approval URL
*/
public ServiceResponse<PayPalCreatePaymentIntentResponse> createPayPalOneTimePaymentIntent(Locale locale,
String currencyCode,
@ -254,13 +276,13 @@ public class DonationsService {
* 400 - request error
* 409 - level requires a valid currency/amount combination that does not match
*
* @param currency 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param payerId Passed as a URL parameter back to returnUrl
* @param paymentId Passed as a URL parameter back to returnUrl
* @param paymentToken Passed as a URL parameter back to returnUrl
* @return Wrapped response with either an error code or a payment id
* @param currency 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount
* @param level The badge level to purchase
* @param payerId Passed as a URL parameter back to returnUrl
* @param paymentId Passed as a URL parameter back to returnUrl
* @param paymentToken Passed as a URL parameter back to returnUrl
* @return Wrapped response with either an error code or a payment id
*/
public ServiceResponse<PayPalConfirmPaymentIntentResponse> confirmPayPalOneTimePaymentIntent(String currency,
String amount,
@ -277,22 +299,23 @@ public class DonationsService {
/**
* Sets up a payment method via PayPal for recurring charges.
*
* <p>
* Response Codes
* 200 - success
* 403 - subscriberId password mismatches OR account authentication is present
* 404 - subscriberId is not found or malformed
*
* @param locale User locale
* @param subscriberId User subscriber id
* @param returnUrl A success URL
* @param cancelUrl A cancel URL
* @return A response with an approval url and token
* @param locale User locale
* @param subscriberId User subscriber id
* @param returnUrl A success URL
* @param cancelUrl A cancel URL
* @return A response with an approval url and token
*/
public ServiceResponse<PayPalCreatePaymentMethodResponse> createPayPalPaymentMethod(Locale locale,
SubscriberId subscriberId,
String returnUrl,
String cancelUrl) {
String cancelUrl)
{
return wrapInServiceResponse(() -> {
PayPalCreatePaymentMethodResponse response = pushServiceSocket.createPayPalPaymentMethod(locale, subscriberId.serialize(), returnUrl, cancelUrl);
return new Pair<>(response, 200);
@ -301,7 +324,7 @@ public class DonationsService {
/**
* Sets the given payment method as the default in PayPal
*
* <p>
* Response Codes
* 200 - success
* 403 - subscriberId password mismatches OR account authentication is present
@ -338,11 +361,15 @@ public class DonationsService {
}
}
private boolean isNewCacheEntryRequired(CacheEntry cacheEntry, Locale locale) {
private <T> boolean isNewCacheEntryRequired(CacheEntry<T> cacheEntry, Locale locale) {
return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale);
}
private interface Producer<T> {
Pair<T, Integer> produce() throws IOException;
}
interface CacheEntryValueProducer<T> {
T produce(Locale locale) throws IOException;
}
}

View file

@ -11,4 +11,4 @@ import com.fasterxml.jackson.annotation.JsonProperty
/**
* Localized bank transfer mandate.
*/
class BankMandate @JsonCreator constructor(@JsonProperty("mandate") mandate: String)
class BankMandate @JsonCreator constructor(@JsonProperty("mandate") val mandate: String)