From 15700b85cb8498fa1a0df6f3ae677293ce31206e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 4 Oct 2023 15:13:34 -0400 Subject: [PATCH] Implement underpinnings of SEPA debit transfer support for donations. --- .../flow/GiftFlowConfirmationFragment.kt | 7 +- .../badges/gifts/flow/GiftFlowViewModel.kt | 1 + .../app/internal/InternalSettingsFragment.kt | 2 +- .../subscription/MonthlyDonationRepository.kt | 4 +- .../subscription/OneTimeDonationRepository.kt | 7 +- .../app/subscription/StripeRepository.kt | 8 +- .../donate/DonateToSignalFragment.kt | 6 +- .../donate/DonateToSignalViewModel.kt | 2 + .../donate/DonationCheckoutDelegate.kt | 21 +- .../donate/card/CreditCardFragment.kt | 2 +- .../donate/gateway/GatewayRequest.kt | 1 + .../donate/gateway/GatewayResponse.kt | 4 +- .../gateway/GatewaySelectorBottomSheet.kt | 14 + .../gateway/GatewaySelectorRepository.kt | 1 + .../donate/gateway/GatewaySelectorState.kt | 3 +- .../gateway/GatewaySelectorViewModel.kt | 6 +- .../PayPalPaymentInProgressViewModel.kt | 8 +- .../StripePaymentInProgressViewModel.kt | 50 +-- .../details/BankTransferDetailsFragment.kt | 302 ++++++++++++++++++ .../details/BankTransferDetailsState.kt | 25 ++ .../details/BankTransferDetailsViewModel.kt | 45 +++ .../donate/transfer/details/IBANValidator.kt | 161 ++++++++++ .../details/IBANVisualTransformation.kt | 43 +++ .../mandate/BankTransferMandateFragment.kt | 175 ++++++++++ .../mandate/BankTransferMandateRepository.kt | 22 ++ .../mandate/BankTransferMandateViewModel.kt | 32 ++ .../app/subscription/errors/DonationError.kt | 29 ++ .../jobs/BoostReceiptRequestResponseJob.java | 43 ++- .../jobs/DonationReceiptRedemptionJob.java | 20 +- .../jobs/SubscriptionKeepAliveJob.java | 4 +- ...SubscriptionReceiptRequestResponseJob.java | 45 +-- .../main/res/navigation/donate_to_signal.xml | 31 ++ app/src/main/res/navigation/gift_flow.xml | 28 ++ app/src/main/res/values/strings.xml | 38 +++ .../transfer/details/IBANValidatorTest.kt | 66 ++++ .../donations/SEPADebitPaymentSource.kt | 18 ++ .../java/org/signal/donations/StripeApi.kt | 41 ++- .../api/services/DonationsService.java | 105 +++--- .../internal/push/BankMandate.kt | 2 +- 39 files changed, 1295 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANVisualTransformation.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateViewModel.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidatorTest.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index 58991304ed..b294bbfc4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index 7c073a0baf..b32055bdbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -39,6 +39,7 @@ class GiftFlowViewModel( val state: Flowable = store.stateFlowable val events: Observable = eventPublisher val snapshot: GiftFlowState get() = store.state + val uiSessionKey: Long = System.currentTimeMillis() init { refresh() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 87ea48cf0e..263aa5e0b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } private fun enqueueSubscriptionRedemption() { - SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue() + SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue() } private fun enqueueSubscriptionKeepAlive() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt index e29dc6b294..803816c09d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index 65450e0e0a..c44fac4805 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index c0a9558fb3..6fcf9e64b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -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 { + Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...") + return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData) + } + data class StatusAndPaymentMethodId( val status: StripeIntentStatus, val paymentMethod: String? diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 3e72b47549..cfcfd68ff9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index 0083f0c9f2..7625e4c4cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -58,6 +58,7 @@ class DonateToSignalViewModel( val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val actions: Observable = _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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index f8559d2483..42fb0c7aa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt index 6b372ce879..ac9e226cf5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -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)!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt index 79954eda64..7778921c50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt @@ -12,6 +12,7 @@ import java.util.Currency @Parcelize data class GatewayRequest( + val uiSessionKey: Long, val donateToSignalType: DonateToSignalType, val badge: Badge, val label: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt index 533fa83c8e..55b4e39d03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt @@ -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 } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index 1d8432865d..33c7ccef95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt index d60ee2bacf..d1415c0bfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt index ba5a8d8098..5ac7a58ee5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index 0ef1a0fc6b..6061cd5134 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -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) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index d8b498e9fd..1be0520133 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -43,7 +43,6 @@ class PayPalPaymentInProgressViewModel( val state: Flowable = 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index 4c06c6186f..4f08dc7538 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -44,8 +44,7 @@ class StripePaymentInProgressViewModel( val state: Flowable = 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(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() } + Single.just(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) { @@ -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 ) + 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()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt new file mode 100644 index 0000000000..48daec68c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -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().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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt new file mode 100644 index 0000000000..df83741177 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsState.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt new file mode 100644 index 0000000000..c58ae449e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsViewModel.kt @@ -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 = 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidator.kt new file mode 100644 index 0000000000..d5a8358cbf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidator.kt @@ -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 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANVisualTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANVisualTransformation.kt new file mode 100644 index 0000000000..12bd098d49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANVisualTransformation.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt new file mode 100644 index 0000000000..65f48c1b00 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateRepository.kt new file mode 100644 index 0000000000..3080b89b34 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateRepository.kt @@ -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 { + return Single + .fromCallable { ApplicationDependencies.getDonationsService().getBankMandate(Locale.getDefault()) } + .flatMap { it.flattenResult() } + .map { it.mandate } + .subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateViewModel.kt new file mode 100644 index 0000000000..1363c4b3c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateViewModel.kt @@ -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 = internalMandate + + init { + disposables += repository.getMandate() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy( + onSuccess = { internalMandate.value = it }, + onError = { internalMandate.value = "Failed to load mandate." } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 460e17c69d..15829d9d80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -113,11 +113,40 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : source to PublishSubject.create() } + private val donationErrorsSubjectUiSessionMap: MutableMap> = mutableMapOf() + @JvmStatic fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable { return donationErrorSubjectSourceMap[donationErrorSource]!! } + fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable { + val subject: Subject = 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? = 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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index c50273a7d0..5b828b2989 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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 response, @NonNull DonationErrorSource donationErrorSource) throws Exception { + private void handleApplicationError(Context context, ServiceResponse 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index d8295a271c..d52a8b849d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java index 0f9a7661e7..2a92798523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 7956eecb10..3bfc48dc66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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); } } } diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 13d940f344..0d340e5791 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -42,6 +42,9 @@ + @@ -206,4 +209,32 @@ app:nullable="false" /> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/gift_flow.xml b/app/src/main/res/navigation/gift_flow.xml index 11abb8386e..c30eab59c7 100644 --- a/app/src/main/res/navigation/gift_flow.xml +++ b/app/src/main/res/navigation/gift_flow.xml @@ -62,6 +62,9 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc46521dab..6d76dd34c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5808,11 +5808,49 @@ Get a %1$s badge for %2$d day Get a %1$s badge for %2$d days + + Bank transfer Credit or debit card Donate for a friend + + + Bank Transfer + + Stripe processes donations made to Signal. Signal does not collect or store your personal information. %1$s + + Learn more + + Continue + + + + Enter your bank details and email address. Your email is used by Stripe to send you updates about your donation. %1$s + + Learn more + + Name on bank account + + IBAN + + Email + + Find account numbers + + Donate %1$s/month + + Donate %1$s + + IBAN number is too short + + IBAN number is too long + + IBAN country code is not supported + + Invalid IBAN number + Cancelling… diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidatorTest.kt new file mode 100644 index 0000000000..bf927277f0 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/IBANValidatorTest.kt @@ -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) + } +} diff --git a/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt new file mode 100644 index 0000000000..73cb7694ac --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt @@ -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 +} diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index a881f00e12..3dcedef4bb 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -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 { + return Single.just(SEPADebitPaymentSource(sepaDebitData)) + } + @WorkerThread private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource { val parameters: Map = 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 diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 10f22a519d..5c1cc87808 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -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 donationsConfigurationCache = new AtomicReference<>(null); + private final AtomicReference> donationsConfigurationCache = new AtomicReference<>(null); + private final AtomicReference> sepaBankMandateCache = new AtomicReference<>(null); - private static class CacheEntry { - private final DonationsConfiguration donationsConfiguration; - private final long expiresAt; - private final Locale locale; + private static class CacheEntry { + 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 getDonationsConfiguration(Locale locale) { - CacheEntry cacheEntryOutsideLock = donationsConfigurationCache.get(); + return getCachedValue( + locale, + donationsConfigurationCache, + pushServiceSocket::getDonationsConfiguration + ); + } + + public ServiceResponse getBankMandate(Locale locale) { + return getCachedValue( + locale, + sepaBankMandateCache, + l -> pushServiceSocket.getBankMandate(l, "SEPA_DEBIT") + ); + } + + private ServiceResponse getCachedValue(Locale locale, + AtomicReference> cachedValueReference, + CacheEntryValueProducer cacheEntryValueProducer + ) + { + CacheEntry cacheEntryOutsideLock = cachedValueReference.get(); if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) { synchronized (this) { - CacheEntry cacheEntryInLock = donationsConfigurationCache.get(); + CacheEntry 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 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 confirmPayPalOneTimePaymentIntent(String currency, String amount, @@ -277,22 +299,23 @@ public class DonationsService { /** * Sets up a payment method via PayPal for recurring charges. - * + *

* 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 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 - * + *

* 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 boolean isNewCacheEntryRequired(CacheEntry cacheEntry, Locale locale) { return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale); } private interface Producer { Pair produce() throws IOException; } + + interface CacheEntryValueProducer { + T produce(Locale locale) throws IOException; + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt index 9a9dbfc589..0ab32bd00b 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BankMandate.kt @@ -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)