Implement underpinnings of SEPA debit transfer support for donations.

This commit is contained in:
Alex Hart
2023-10-04 15:13:34 -04:00
committed by Nicholas Tinsley
parent 3dfd1c98ba
commit 15700b85cb
39 changed files with 1295 additions and 127 deletions
@@ -83,7 +83,7 @@ class GiftFlowConfirmationFragment :
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI) keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.GIFT) donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext()) processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog) .setView(R.layout.processing_payment_dialog)
@@ -106,6 +106,7 @@ class GiftFlowConfirmationFragment :
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet( GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
with(viewModel.snapshot) { with(viewModel.snapshot) {
GatewayRequest( GatewayRequest(
uiSessionKey = viewModel.uiSessionKey,
donateToSignalType = DonateToSignalType.GIFT, donateToSignalType = DonateToSignalType.GIFT,
badge = giftBadge!!, badge = giftBadge!!,
label = getString(R.string.preferences__one_time), label = getString(R.string.preferences__one_time),
@@ -262,6 +263,10 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest)) findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
} }
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) { override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
val mainActivityIntent = MainActivity.clearTop(requireContext()) val mainActivityIntent = MainActivity.clearTop(requireContext())
@@ -39,6 +39,7 @@ class GiftFlowViewModel(
val state: Flowable<GiftFlowState> = store.stateFlowable val state: Flowable<GiftFlowState> = store.stateFlowable
val events: Observable<DonationEvent> = eventPublisher val events: Observable<DonationEvent> = eventPublisher
val snapshot: GiftFlowState get() = store.state val snapshot: GiftFlowState get() = store.state
val uiSessionKey: Long = System.currentTimeMillis()
init { init {
refresh() refresh()
@@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
} }
private fun enqueueSubscriptionRedemption() { private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue() SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
} }
private fun enqueueSubscriptionKeepAlive() { private fun enqueueSubscriptionKeepAlive() {
@@ -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) return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation -> .flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber() val subscriber = SignalStore.donationsValues().requireSubscriber()
@@ -186,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1) val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState -> SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
if (jobState.isComplete) { if (jobState.isComplete) {
finalJobState = jobState finalJobState = jobState
countDownLatch.countDown() countDownLatch.countDown()
@@ -111,7 +111,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
badgeRecipient: RecipientId, badgeRecipient: RecipientId,
additionalMessage: String?, additionalMessage: String?,
badgeLevel: Long, badgeLevel: Long,
donationProcessor: DonationProcessor donationProcessor: DonationProcessor,
uiSessionKey: Long
): Completable { ): Completable {
val isBoost = badgeRecipient == Recipient.self().id val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
@@ -131,9 +132,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1) val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) { val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor) BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
} else { } else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor) BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
} }
chain.enqueue { _, jobState -> chain.enqueue { _, jobState ->
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -46,7 +47,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { 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 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()) private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
fun isGooglePayAvailable(): Completable { fun isGooglePayAvailable(): Completable {
@@ -251,6 +252,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
} }
} }
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
}
data class StatusAndPaymentMethodId( data class StatusAndPaymentMethodId(
val status: StripeIntentStatus, val status: StripeIntentStatus,
val paymentMethod: String? val paymentMethod: String?
@@ -106,7 +106,7 @@ class DonateToSignalFragment :
} }
override fun bindAdapter(adapter: MappingAdapter) { 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!! val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -417,6 +417,10 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest)) findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
} }
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) { override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge)) findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
} }
@@ -58,6 +58,7 @@ class DonateToSignalViewModel(
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread()) val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
val uiSessionKey: Long = System.currentTimeMillis()
init { init {
initializeOneTimeDonationState(oneTimeDonationRepository) initializeOneTimeDonationState(oneTimeDonationRepository)
@@ -178,6 +179,7 @@ class DonateToSignalViewModel(
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest { private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
val amount = getAmount(snapshot) val amount = getAmount(snapshot)
return GatewayRequest( return GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = snapshot.donateToSignalType, donateToSignalType = snapshot.donateToSignalType,
badge = snapshot.badge!!, badge = snapshot.badge!!,
label = snapshot.badge!!.description, label = snapshot.badge!!.description,
@@ -43,6 +43,7 @@ import java.util.Currency
class DonationCheckoutDelegate( class DonationCheckoutDelegate(
private val fragment: Fragment, private val fragment: Fragment,
private val callback: Callback, private val callback: Callback,
private val uiSessionKey: Long,
errorSource: DonationErrorSource, errorSource: DonationErrorSource,
vararg additionalSources: DonationErrorSource vararg additionalSources: DonationErrorSource
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
@@ -65,7 +66,7 @@ class DonationCheckoutDelegate(
init { init {
fragment.viewLifecycleOwner.lifecycle.addObserver(this) fragment.viewLifecycleOwner.lifecycle.addObserver(this)
ErrorHandler().attach(fragment, callback, errorSource, *additionalSources) ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
} }
override fun onCreate(owner: LifecycleOwner) { override fun onCreate(owner: LifecycleOwner) {
@@ -100,6 +101,7 @@ class DonationCheckoutDelegate(
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse) GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse) GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse) GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
} }
} else { } else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}") error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
@@ -154,6 +156,10 @@ class DonationCheckoutDelegate(
callback.navigateToCreditCardForm(gatewayResponse.request) callback.navigateToCreditCardForm(gatewayResponse.request)
} }
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse.request)
}
private fun registerGooglePayCallback() { private fun registerGooglePayCallback() {
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy( disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult -> onNext = { paymentResult ->
@@ -206,7 +212,7 @@ class DonationCheckoutDelegate(
private var errorDialog: DialogInterface? = null private var errorDialog: DialogInterface? = null
private var userCancelledFlowCallback: UserCancelledFlowCallback? = 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.fragment = fragment
this.userCancelledFlowCallback = userCancelledFlowCallback this.userCancelledFlowCallback = userCancelledFlowCallback
@@ -218,6 +224,8 @@ class DonationCheckoutDelegate(
additionalSources.forEach { source -> additionalSources.forEach { source ->
disposables += registerErrorSource(source) disposables += registerErrorSource(source)
} }
disposables += registerUiSession(uiSessionKey)
} }
override fun onDestroy(owner: LifecycleOwner) { 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) { private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) { if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true) Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
@@ -281,6 +297,7 @@ class DonationCheckoutDelegate(
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
fun onPaymentComplete(gatewayRequest: GatewayRequest) fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed() fun onProcessorActionProcessed()
} }
@@ -54,7 +54,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
DonateToSignalType.GIFT -> DonationErrorSource.GIFT DonateToSignalType.GIFT -> DonationErrorSource.GIFT
} }
DonationCheckoutDelegate.ErrorHandler().attach(this, null, errorSource) DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle -> setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!! val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
@@ -12,6 +12,7 @@ import java.util.Currency
@Parcelize @Parcelize
data class GatewayRequest( data class GatewayRequest(
val uiSessionKey: Long,
val donateToSignalType: DonateToSignalType, val donateToSignalType: DonateToSignalType,
val badge: Badge, val badge: Badge,
val label: String, val label: String,
@@ -9,13 +9,15 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
enum class Gateway { enum class Gateway {
GOOGLE_PAY, GOOGLE_PAY,
PAYPAL, PAYPAL,
CREDIT_CARD; CREDIT_CARD,
SEPA_DEBIT;
fun toPaymentSourceType(): PaymentSourceType { fun toPaymentSourceType(): PaymentSourceType {
return when (this) { return when (this) {
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
PAYPAL -> PaymentSourceType.PayPal PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
} }
} }
} }
@@ -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) space(16.dp)
} }
} }
@@ -17,6 +17,7 @@ class GatewaySelectorRepository(
when (it) { when (it) {
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL) "PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY) "CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
else -> listOf() else -> listOf()
} }
}.flatten().toSet() }.flatten().toSet()
@@ -7,5 +7,6 @@ data class GatewaySelectorState(
val badge: Badge, val badge: Badge,
val isGooglePayAvailable: Boolean = false, val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false, val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false
) )
@@ -24,7 +24,8 @@ class GatewaySelectorViewModel(
badge = args.request.badge, badge = args.request.badge,
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType), isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, 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() private val disposables = CompositeDisposable()
@@ -41,7 +42,8 @@ class GatewaySelectorViewModel(
loading = false, loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD), isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY), 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)
) )
} }
} }
@@ -43,7 +43,6 @@ class PayPalPaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
override fun onCleared() { override fun onCleared() {
store.dispose() store.dispose()
disposables.clear() disposables.clear()
@@ -82,7 +81,7 @@ class PayPalPaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true) Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE } 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( .subscribeBy(
onComplete = { onComplete = {
Log.w(TAG, "Completed subscription update", true) Log.w(TAG, "Completed subscription update", true)
@@ -157,7 +156,8 @@ class PayPalPaymentInProgressViewModel(
badgeRecipient = request.recipientId, badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage, additionalMessage = request.additionalMessage,
badgeLevel = request.level, badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL donationProcessor = DonationProcessor.PAYPAL,
uiSessionKey = request.uiSessionKey
) )
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@@ -190,7 +190,7 @@ class PayPalPaymentInProgressViewModel(
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) } .flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) } .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( .subscribeBy(
onError = { throwable -> onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true) Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
@@ -44,8 +44,7 @@ class StripePaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
private var paymentData: PaymentData? = null private var stripePaymentData: StripePaymentData? = null
private var cardData: StripeApi.CardData? = null
override fun onCleared() { override fun onCleared() {
disposables.clear() disposables.clear()
@@ -87,19 +86,18 @@ class StripePaymentInProgressViewModel(
} }
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider { private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
val paymentData = this.paymentData return when (val data = stripePaymentData) {
val cardData = this.cardData is StripePaymentData.GooglePay -> PaymentSourceProvider(
return when {
paymentData == null && cardData == null -> error("No payment provider available.")
paymentData != null && cardData != null -> error("Too many providers available")
paymentData != null -> PaymentSourceProvider(
PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Stripe.GooglePay,
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() } Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
) )
cardData != null -> PaymentSourceProvider( is StripePaymentData.CreditCard -> PaymentSourceProvider(
PaymentSourceType.Stripe.CreditCard, 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.") else -> error("This should never happen.")
} }
@@ -107,23 +105,26 @@ class StripePaymentInProgressViewModel(
fun providePaymentData(paymentData: PaymentData) { fun providePaymentData(paymentData: PaymentData) {
requireNoPaymentInformation() requireNoPaymentInformation()
this.paymentData = paymentData this.stripePaymentData = StripePaymentData.GooglePay(paymentData)
} }
fun provideCardData(cardData: StripeApi.CardData) { fun provideCardData(cardData: StripeApi.CardData) {
requireNoPaymentInformation() requireNoPaymentInformation()
this.cardData = cardData this.stripePaymentData = StripePaymentData.CreditCard(cardData)
}
fun provideSEPADebitData(bankData: StripeApi.SEPADebitData) {
requireNoPaymentInformation()
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
} }
private fun requireNoPaymentInformation() { private fun requireNoPaymentInformation() {
require(paymentData == null) require(stripePaymentData == null)
require(cardData == null)
} }
private fun clearPaymentInformation() { private fun clearPaymentInformation() {
Log.d(TAG, "Cleared payment information.", true) Log.d(TAG, "Cleared payment information.", true)
paymentData = null stripePaymentData = null
cardData = null
} }
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) { private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
@@ -132,7 +133,7 @@ class StripePaymentInProgressViewModel(
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe) 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) Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE } store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -201,7 +202,8 @@ class StripePaymentInProgressViewModel(
badgeRecipient = request.recipientId, badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage, additionalMessage = request.additionalMessage,
badgeLevel = request.level, badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE donationProcessor = DonationProcessor.STRIPE,
uiSessionKey = request.uiSessionKey
) )
} }
}.subscribeBy( }.subscribeBy(
@@ -246,7 +248,7 @@ class StripePaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true) Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE } 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( .subscribeBy(
onComplete = { onComplete = {
Log.w(TAG, "Completed subscription update", true) Log.w(TAG, "Completed subscription update", true)
@@ -271,6 +273,12 @@ class StripePaymentInProgressViewModel(
val paymentSource: Single<StripeApi.PaymentSource> val paymentSource: Single<StripeApi.PaymentSource>
) )
private sealed interface StripePaymentData {
class GooglePay(val paymentData: PaymentData) : StripePaymentData
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
}
class Factory( class Factory(
private val stripeRepository: StripeRepository, private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()), private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
@@ -0,0 +1,302 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
*/
class BankTransferDetailsFragment : ComposeFragment() {
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
@Composable
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
)
}
}
BankTransferDetailsContent(
state = state,
onNavigationClick = this::onNavigationClick,
onNameChanged = viewModel::onNameChanged,
onIBANChanged = viewModel::onIBANChanged,
onEmailChanged = viewModel::onEmailChanged,
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
donateLabel = donateLabel
)
}
private fun onNavigationClick() {
findNavController().popBackStack()
}
private fun onFindAccountNumbersClicked() {
// TODO [sepa] -- FindAccountNumbersBottomSheet
}
private fun onDonateClick() {
stripePaymentViewModel.provideSEPADebitData(viewModel.state.value.asSEPADebitData())
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
)
)
}
}
@Preview
@Composable
private fun BankTransferDetailsContentPreview() {
SignalTheme {
BankTransferDetailsContent(
state = BankTransferDetailsState(
name = "Miles Morales"
),
onNavigationClick = {},
onNameChanged = {},
onIBANChanged = {},
onEmailChanged = {},
onFindAccountNumbersClicked = {},
onDonateClick = {},
onIBANFocusChanged = {},
donateLabel = "Donate $5/month"
)
}
}
@Composable
private fun BankTransferDetailsContent(
state: BankTransferDetailsState,
onNavigationClick: () -> Unit,
onNameChanged: (String) -> Unit,
onIBANChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFindAccountNumbersClicked: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
donateLabel: String
) {
Scaffolds.Settings(
title = "Bank transfer",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(it)
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
) {
item {
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
)
}
item {
TextField(
value = state.iban,
onValueChange = onIBANChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__iban))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
isError = state.ibanValidity.isError,
supportingText = {
if (state.ibanValidity.isError) {
Text(
text = when (state.ibanValidity) {
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
else -> error("Unexpected error.")
}
)
}
},
visualTransformation = IBANVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.focusRequester(focusRequester)
)
}
item {
TextField(
value = state.name,
onValueChange = onNameChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__name_on_bank_account))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = onFindAccountNumbersClicked
) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
}
}
}
}
Buttons.LargeTonal(
enabled = state.canProceed,
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = donateLabel)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
@@ -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
)
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class BankTransferDetailsViewModel : ViewModel() {
companion object {
private const val IBAN_MAX_CHARACTER_COUNT = 34
}
private val internalState = mutableStateOf(BankTransferDetailsState())
val state: State<BankTransferDetailsState> = internalState
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name
)
}
fun onIBANFocusChanged(isFocused: Boolean) {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
}
fun onIBANChanged(iban: String) {
internalState.value = internalState.value.copy(
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
ibanValidity = IBANValidator.validate(internalState.value.iban, true)
)
}
fun onEmailChanged(email: String) {
internalState.value = internalState.value.copy(
email = email
)
}
}
@@ -0,0 +1,161 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import java.math.BigInteger
object IBANValidator {
private val countryCodeToLength: Map<String, Int> by lazy {
mapOf(
"AL" to 28,
"AD" to 24,
"AT" to 20,
"AZ" to 28,
"BH" to 22,
"BY" to 28,
"BE" to 16,
"BA" to 20,
"BR" to 29,
"BG" to 22,
"CR" to 22,
"HR" to 21,
"CY" to 28,
"CZ" to 24,
"DK" to 18,
"DO" to 28,
"TL" to 23,
"EG" to 29,
"SV" to 28,
"EE" to 20,
"FO" to 18,
"FI" to 18,
"FR" to 27,
"GE" to 22,
"DE" to 22,
"GI" to 23,
"GR" to 27,
"GL" to 18,
"GT" to 28,
"HU" to 28,
"IS" to 26,
"IQ" to 23,
"IE" to 22,
"IL" to 23,
"IT" to 27,
"JO" to 30,
"KZ" to 20,
"XK" to 20,
"KW" to 30,
"LV" to 21,
"LB" to 28,
"LY" to 25,
"LI" to 21,
"LT" to 20,
"LU" to 20,
"MT" to 31,
"MR" to 27,
"MU" to 30,
"MC" to 27,
"MD" to 24,
"ME" to 22,
"NL" to 18,
"MK" to 19,
"NO" to 15,
"PK" to 24,
"PS" to 29,
"PL" to 28,
"PT" to 25,
"QA" to 29,
"RO" to 24,
"RU" to 33,
"LC" to 32,
"SM" to 27,
"ST" to 25,
"SA" to 24,
"RS" to 22,
"SC" to 31,
"SK" to 24,
"SI" to 19,
"ES" to 24,
"SD" to 18,
"SE" to 24,
"CH" to 21,
"TN" to 24,
"TR" to 26,
"UA" to 29,
"AE" to 23,
"GB" to 22,
"VA" to 22,
"VG" to 24
)
}
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.isEmpty()) {
return Validity.POTENTIALLY_VALID
}
val lengthValidity = validateLength(iban, isIBANFieldFocused)
if (lengthValidity != Validity.COMPLETELY_VALID) {
return lengthValidity
}
val countryAndCheck = iban.take(4)
val rearranged = iban.drop(4) + countryAndCheck
val expanded = rearranged.map {
if (it.isLetter()) {
(it - 'A') + 10
} else if (it.isDigit()) {
it.digitToInt()
} else {
return Validity.INVALID_CHARACTERS
}
}.joinToString("")
val bigInteger = BigInteger(expanded)
if (bigInteger.mod(BigInteger.valueOf(97L)) == BigInteger.ONE) {
return Validity.COMPLETELY_VALID
}
return Validity.INVALID_MOD_97
}
private fun validateLength(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.length < 2) {
return if (isIBANFieldFocused) {
Validity.POTENTIALLY_VALID
} else {
Validity.TOO_SHORT
}
}
val countryCode = iban.take(2)
val requiredLength = countryCodeToLength[countryCode] ?: -1
if (requiredLength == -1) {
return Validity.INVALID_COUNTRY
}
if (requiredLength > iban.length) {
return if (isIBANFieldFocused) Validity.POTENTIALLY_VALID else Validity.TOO_SHORT
}
if (requiredLength < iban.length) {
return Validity.TOO_LONG
}
return Validity.COMPLETELY_VALID
}
enum class Validity(val isError: Boolean) {
TOO_SHORT(true),
TOO_LONG(true),
INVALID_COUNTRY(true),
INVALID_CHARACTERS(true),
INVALID_MOD_97(true),
POTENTIALLY_VALID(false),
COMPLETELY_VALID(false)
}
}
@@ -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)
}
}
}
@@ -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))
}
}
}
}
@@ -0,0 +1,22 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
class BankTransferMandateRepository {
fun getMandate(): Single<String> {
return Single
.fromCallable { ApplicationDependencies.getDonationsService().getBankMandate(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.map { it.mandate }
.subscribeOn(Schedulers.io())
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
class BankTransferMandateViewModel(
repository: BankTransferMandateRepository = BankTransferMandateRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val internalMandate = mutableStateOf("")
val mandate: State<String> = internalMandate
init {
disposables += repository.getMandate()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = { internalMandate.value = it },
onError = { internalMandate.value = "Failed to load mandate." }
)
}
}
@@ -113,11 +113,40 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
source to PublishSubject.create() source to PublishSubject.create()
} }
private val donationErrorsSubjectUiSessionMap: MutableMap<Long, Subject<DonationError>> = mutableMapOf()
@JvmStatic @JvmStatic
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> { fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
return donationErrorSubjectSourceMap[donationErrorSource]!! return donationErrorSubjectSourceMap[donationErrorSource]!!
} }
fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable<DonationError> {
val subject: Subject<DonationError> = donationErrorsSubjectUiSessionMap[uiSessionKey] ?: PublishSubject.create()
donationErrorsSubjectUiSessionMap[uiSessionKey] = subject
return subject
}
@JvmStatic
fun routeBackgroundError(context: Context, uiSessionKey: Long, error: DonationError) {
if (error.source == DonationErrorSource.GIFT_REDEMPTION) {
routeDonationError(context, error)
return
}
val subject: Subject<DonationError>? = donationErrorsSubjectUiSessionMap[uiSessionKey]
when {
subject != null && subject.hasObservers() -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
subject.onNext(error)
}
else -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
DonationErrorNotifications.displayErrorNotification(context, error)
}
}
}
/** /**
* Route a given donation error, which will either pipe it out to an appropriate subject * 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. * or, if the subject has no observers, post it as a notification.
@@ -49,6 +49,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String DATA_ERROR_SOURCE = "data.error.source"; 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_BADGE_LEVEL = "data.badge.level";
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor"; 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; private ReceiptCredentialRequestContext requestContext;
@@ -56,8 +57,9 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private final String paymentIntentId; private final String paymentIntentId;
private final long badgeLevel; private final long badgeLevel;
private final DonationProcessor donationProcessor; 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( return new BoostReceiptRequestResponseJob(
new Parameters new Parameters
.Builder() .Builder()
@@ -70,13 +72,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
paymentIntentId, paymentIntentId,
donationErrorSource, donationErrorSource,
badgeLevel, badgeLevel,
donationProcessor donationProcessor,
uiSessionKey
); );
} }
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId, @NonNull DonationProcessor donationProcessor) { public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor); @NonNull DonationProcessor donationProcessor,
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(); long uiSessionKey)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost(); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob(); MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -91,9 +97,10 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@NonNull RecipientId recipientId, @NonNull RecipientId recipientId,
@Nullable String additionalMessage, @Nullable String additionalMessage,
long badgeLevel, 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); GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
@@ -107,7 +114,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@NonNull String paymentIntentId, @NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource, @NonNull DonationErrorSource donationErrorSource,
long badgeLevel, long badgeLevel,
@NonNull DonationProcessor donationProcessor) @NonNull DonationProcessor donationProcessor,
long uiSessionKey)
{ {
super(parameters); super(parameters);
this.requestContext = requestContext; this.requestContext = requestContext;
@@ -115,6 +123,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
this.donationErrorSource = donationErrorSource; this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel; this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor; this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
} }
@Override @Override
@@ -122,7 +131,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
JsonJobData.Builder builder = new JsonJobData.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId) JsonJobData.Builder builder = new JsonJobData.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize()) .putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel) .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) { if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize()); builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@@ -168,7 +178,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get()); ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
if (!isCredentialValid(receiptCredential)) { if (!isCredentialValid(receiptCredential)) {
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource)); DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
throw new IOException("Could not validate receipt credential"); throw new IOException("Could not validate receipt credential");
} }
@@ -183,7 +193,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
} }
} }
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception { private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
Throwable applicationException = response.getApplicationError().get(); Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) { switch (response.getStatus()) {
case 204: case 204:
@@ -191,15 +201,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
throw new RetryableException(); throw new RetryableException();
case 400: case 400:
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true); 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); throw new Exception(applicationException);
case 402: case 402:
Log.w(TAG, "User payment failed.", applicationException, true); 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); throw new Exception(applicationException);
case 409: case 409:
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true); 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); throw new Exception(applicationException);
default: default:
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true); 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)); long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode()); String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor); DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
try { try {
if (data.hasString(DATA_REQUEST_BYTES)) { if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES); byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob); 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 { } else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor); return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
} }
} catch (InvalidInputException e) { } catch (InvalidInputException e) {
throw new IllegalStateException(e); throw new IllegalStateException(e);
@@ -43,16 +43,19 @@ public class DonationReceiptRedemptionJob extends BaseJob {
public static final String DATA_ERROR_SOURCE = "data.error.source"; 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_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary"; 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 long giftMessageId;
private final boolean makePrimary; private final boolean makePrimary;
private final DonationErrorSource errorSource; 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( return new DonationReceiptRedemptionJob(
NO_ID, NO_ID,
false, false,
errorSource, errorSource,
uiSessionKey,
new Job.Parameters new Job.Parameters
.Builder() .Builder()
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
@@ -63,11 +66,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.build()); .build());
} }
public static DonationReceiptRedemptionJob createJobForBoost() { public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey) {
return new DonationReceiptRedemptionJob( return new DonationReceiptRedemptionJob(
NO_ID, NO_ID,
false, false,
DonationErrorSource.BOOST, DonationErrorSource.BOOST,
uiSessionKey,
new Job.Parameters new Job.Parameters
.Builder() .Builder()
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
@@ -78,7 +82,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
} }
public static JobManager.Chain createJobChainForKeepAlive() { public static JobManager.Chain createJobChainForKeepAlive() {
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE); DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L);
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob(); RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob(); MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -93,6 +97,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
messageId, messageId,
primary, primary,
DonationErrorSource.GIFT_REDEMPTION, DonationErrorSource.GIFT_REDEMPTION,
-1L,
new Job.Parameters new Job.Parameters
.Builder() .Builder()
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
@@ -110,11 +115,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.then(multiDeviceProfileContentUpdateJob); .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); super(parameters);
this.giftMessageId = giftMessageId; this.giftMessageId = giftMessageId;
this.makePrimary = primary; this.makePrimary = primary;
this.errorSource = errorSource; this.errorSource = errorSource;
this.uiSessionKey = uiSessionKey;
} }
@Override @Override
@@ -123,6 +129,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.putString(DATA_ERROR_SOURCE, errorSource.serialize()) .putString(DATA_ERROR_SOURCE, errorSource.serialize())
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId) .putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary) .putBoolean(DATA_PRIMARY, makePrimary)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.serialize(); .serialize();
} }
@@ -185,7 +192,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
throw new RetryableException(); throw new RetryableException();
} else { } else {
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true); 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()); throw new IOException(response.getApplicationError().get());
} }
} else if (response.getExecutionError().isPresent()) { } else if (response.getExecutionError().isPresent()) {
@@ -288,8 +295,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID); long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false); boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource); 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);
} }
} }
} }
@@ -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); 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().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
SignalStore.donationsValues().refreshSubscriptionRequestCredential(); SignalStore.donationsValues().refreshSubscriptionRequestCredential();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true).enqueue(); SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) { } else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) {
if (SignalStore.donationsValues().getSubscriptionRequestCredential() == null) { 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); 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); 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()) { } else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()) {
if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) { if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) {
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true); Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);
@@ -47,13 +47,15 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String DATA_REQUEST_BYTES = "data.request.bytes"; 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_SUBSCRIBER_ID = "data.subscriber.id";
private static final String DATA_IS_FOR_KEEP_ALIVE = "data.is.for.keep.alive"; 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(); public static final Object MUTEX = new Object();
private final SubscriberId subscriberId; private final SubscriberId subscriberId;
private final boolean isForKeepAlive; 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( return new SubscriptionReceiptRequestResponseJob(
new Parameters new Parameters
.Builder() .Builder()
@@ -64,18 +66,19 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
.setMaxAttempts(Parameters.UNLIMITED) .setMaxAttempts(Parameters.UNLIMITED)
.build(), .build(),
subscriberId, subscriberId,
isForKeepAlive isForKeepAlive,
uiSessionKey
); );
} }
public static JobManager.Chain createSubscriptionContinuationJobChain() { public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey) {
return createSubscriptionContinuationJobChain(false); 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(); Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive); SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource()); DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription(); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob(); MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -88,17 +91,20 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters, private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
@NonNull SubscriberId subscriberId, @NonNull SubscriberId subscriberId,
boolean isForKeepAlive) boolean isForKeepAlive,
long uiSessionKey)
{ {
super(parameters); super(parameters);
this.subscriberId = subscriberId; this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive; this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
} }
@Override @Override
public @Nullable byte[] serialize() { public @Nullable byte[] serialize() {
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes()) 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(); return builder.serialize();
} }
@@ -189,7 +195,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get()); ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get());
if (!isCredentialValid(subscription, receiptCredential)) { 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"); throw new IOException("Could not validate receipt credential");
} }
@@ -215,7 +221,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
return activeSubscription.getResult().get(); return activeSubscription.getResult().get();
} else if (activeSubscription.getApplicationError().isPresent()) { } else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true); 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()); throw new IOException(activeSubscription.getApplicationError().get());
} else { } else {
throw new RetryableException(); throw new RetryableException();
@@ -252,18 +258,18 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
throw new RetryableException(); throw new RetryableException();
case 400: case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true); 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()); throw new Exception(response.getApplicationError().get());
case 402: case 402:
Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true); Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true);
throw new RetryableException(); throw new RetryableException();
case 403: case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true); 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()); throw new Exception(response.getApplicationError().get());
case 404: case 404:
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true); 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()); throw new Exception(response.getApplicationError().get());
case 409: case 409:
onAlreadyRedeemed(response); 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); 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) { } else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) {
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true); 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); 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 { } else {
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true); 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(), getErrorSource(),
new Exception("Got a failure status from the subscription object.") 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()); setOutputData(new JsonJobData.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).serialize());
} else { } else {
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true); 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()); 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); boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null); String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null; byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
ReceiptCredentialRequestContext requestContext; ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) { 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);
} }
} }
} }
@@ -42,6 +42,9 @@
<action <action
android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment" android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment"
app:destination="@id/paypalPaymentInProgressFragment" /> app:destination="@id/paypalPaymentInProgressFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_bankTransferMandateFragment"
app:destination="@id/bankTransferMandateFragment" />
</fragment> </fragment>
@@ -206,4 +209,32 @@
app:nullable="false" /> app:nullable="false" />
</dialog> </dialog>
<fragment
android:id="@+id/bankTransferMandateFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
android:label="bank_transfer_mandate_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
app:destination="@id/bankTransferDetailsFragment" />
</fragment>
<fragment
android:id="@+id/bankTransferDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
android:label="bank_transfer_details_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferDetailsFragment_to_stripePaymentInProgressFragment"
app:destination="@id/stripePaymentInProgressFragment" />
</fragment>
</navigation> </navigation>
+28
View File
@@ -62,6 +62,9 @@
<action <action
android:id="@+id/action_giftFlowConfirmationFragment_to_paypalPaymentInProgressFragment" android:id="@+id/action_giftFlowConfirmationFragment_to_paypalPaymentInProgressFragment"
app:destination="@id/paypalPaymentInProgressFragment" /> app:destination="@id/paypalPaymentInProgressFragment" />
<action
android:id="@+id/action_giftFlowConfirmationFragment_to_bankTransferMandateFragment"
app:destination="@id/bankTransferMandateFragment" />
</fragment> </fragment>
<dialog <dialog
@@ -186,4 +189,29 @@
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest" app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" /> app:nullable="false" />
</dialog> </dialog>
<fragment
android:id="@+id/bankTransferMandateFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
android:label="bank_transfer_mandate_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
app:destination="@id/bankTransferDetailsFragment" />
</fragment>
<fragment
android:id="@+id/bankTransferDetailsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
android:label="bank_transfer_details_fragment">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</fragment>
</navigation> </navigation>
+38
View File
@@ -5808,11 +5808,49 @@
<item quantity="one">Get a %1$s badge for %2$d day</item> <item quantity="one">Get a %1$s badge for %2$d day</item>
<item quantity="other">Get a %1$s badge for %2$d days</item> <item quantity="other">Get a %1$s badge for %2$d days</item>
</plurals> </plurals>
<!-- Button label for paying with a bank transfer -->
<string name="GatewaySelectorBottomSheet__bank_transfer">Bank transfer</string>
<!-- Button label for paying with a credit card --> <!-- Button label for paying with a credit card -->
<string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string> <string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string>
<!-- Sheet summary when giving donating for a friend --> <!-- Sheet summary when giving donating for a friend -->
<string name="GatewaySelectorBottomSheet__donate_for_a_friend">Donate for a friend</string> <string name="GatewaySelectorBottomSheet__donate_for_a_friend">Donate for a friend</string>
<!-- BankTransferMandateFragment -->
<!-- Title of screen displaying the bank transfer mandate -->
<string name="BankTransferMandateFragment__bank_transfer">Bank Transfer</string>
<!-- Subtitle of screen displaying the bank transfer mandate, placeholder is 'Learn more' -->
<string name="BankTransferMandateFragment__stripe_processes_donations">Stripe processes donations made to Signal. Signal does not collect or store your personal information. %1$s</string>
<!-- Subtitle learn more of screen displaying bank transfer mandate -->
<string name="BankTransferMandateFragment__learn_more">Learn more</string>
<!-- Button label to continue with transfer -->
<string name="BankTransferMandateFragment__continue">Continue</string>
<!-- BankTransferDetailsFragment -->
<!-- Subtext explaining how email is used. Placeholder is 'Learn more' -->
<string name="BankTransferDetailsFragment__enter_your_bank_details">Enter your bank details and email address. Your email is used by Stripe to send you updates about your donation. %1$s</string>
<!-- Subtext learn more link text -->
<string name="BankTransferDetailsFragment__learn_more">Learn more</string>
<!-- Text field label for name on bank account -->
<string name="BankTransferDetailsFragment__name_on_bank_account">Name on bank account</string>
<!-- Text field label for IBAN -->
<string name="BankTransferDetailsFragment__iban">IBAN</string>
<!-- Text field label for email -->
<string name="BankTransferDetailsFragment__email">Email</string>
<!-- Text label for button to show user how to find their IBAN number -->
<string name="BankTransferDetailsFragment__find_account_numbers">Find account numbers</string>
<!-- Donate button label for monthly subscription -->
<string name="BankTransferDetailsFragment__donate_s_month">Donate %1$s/month</string>
<!-- Donate button label for one-time -->
<string name="BankTransferDetailsFragment__donate_s">Donate %1$s</string>
<!-- Error label for IBAN field when number is too short -->
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_short">IBAN number is too short</string>
<!-- Error label for IBAN field when number is too long -->
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_long">IBAN number is too long</string>
<!-- Error label for IBAN field when country is not supported -->
<string name="BankTransferDetailsFragment__iban_country_code_is_not_supported">IBAN country code is not supported</string>
<!-- Error label for IBAN field when number is invalid -->
<string name="BankTransferDetailsFragment__invalid_iban_nubmer">Invalid IBAN number</string>
<!-- StripePaymentInProgressFragment --> <!-- StripePaymentInProgressFragment -->
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string> <string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>
@@ -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)
}
}
@@ -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
}
@@ -27,7 +27,8 @@ class StripeApi(
private val configuration: Configuration, private val configuration: Configuration,
private val paymentIntentFetcher: PaymentIntentFetcher, private val paymentIntentFetcher: PaymentIntentFetcher,
private val setupIntentHelper: SetupIntentHelper, private val setupIntentHelper: SetupIntentHelper,
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient,
private val userAgent: String
) { ) {
private val objectMapper = jsonMapper { private val objectMapper = jsonMapper {
@@ -119,6 +120,12 @@ class StripeApi(
"return_url" to RETURN_URL_3DS "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 -> val (nextActionUri, returnUri) = postForm("payment_intents/${paymentIntent.intentId}/confirm", parameters).use { response ->
getNextAction(response) getNextAction(response)
} }
@@ -198,6 +205,10 @@ class StripeApi(
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }
fun createPaymentSourceFromSEPADebitData(sepaDebitData: SEPADebitData): Single<PaymentSource> {
return Single.just(SEPADebitPaymentSource(sepaDebitData))
}
@WorkerThread @WorkerThread
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource { private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
val parameters: Map<String, String> = mutableMapOf( val parameters: Map<String, String> = mutableMapOf(
@@ -218,7 +229,13 @@ class StripeApi(
} }
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String { 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() val body = response.body()
if (body != null) { if (body != null) {
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) } 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 tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf( val parameters = mutableMapOf(
"card[token]" to tokenId, "card[token]" to tokenId,
@@ -532,6 +560,13 @@ class StripeApi(
val cvc: Int val cvc: Int
) : Parcelable ) : Parcelable
@Parcelize
data class SEPADebitData(
val iban: String,
val name: String,
val email: String
) : Parcelable
interface PaymentSource { interface PaymentSource {
val type: PaymentSourceType val type: PaymentSourceType
fun parameterize(): JSONObject fun parameterize(): JSONObject
@@ -17,6 +17,7 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; 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.DonationProcessor;
import org.whispersystems.signalservice.internal.push.DonationsConfiguration; import org.whispersystems.signalservice.internal.push.DonationsConfiguration;
import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.push.PushServiceSocket;
@@ -38,17 +39,18 @@ public class DonationsService {
private final PushServiceSocket pushServiceSocket; private final PushServiceSocket pushServiceSocket;
private final AtomicReference<CacheEntry> donationsConfigurationCache = new AtomicReference<>(null); private final AtomicReference<CacheEntry<DonationsConfiguration>> donationsConfigurationCache = new AtomicReference<>(null);
private final AtomicReference<CacheEntry<BankMandate>> sepaBankMandateCache = new AtomicReference<>(null);
private static class CacheEntry { private static class CacheEntry<T> {
private final DonationsConfiguration donationsConfiguration; private final T cachedValue;
private final long expiresAt; private final long expiresAt;
private final Locale locale; private final Locale locale;
private CacheEntry(DonationsConfiguration donationsConfiguration, long expiresAt, Locale locale) { private CacheEntry(T cachedValue, long expiresAt, Locale locale) {
this.donationsConfiguration = donationsConfiguration; this.cachedValue = cachedValue;
this.expiresAt = expiresAt; this.expiresAt = expiresAt;
this.locale = locale; this.locale = locale;
} }
} }
@@ -107,22 +109,42 @@ public class DonationsService {
} }
public ServiceResponse<DonationsConfiguration> getDonationsConfiguration(Locale locale) { public ServiceResponse<DonationsConfiguration> getDonationsConfiguration(Locale locale) {
CacheEntry cacheEntryOutsideLock = donationsConfigurationCache.get(); return getCachedValue(
locale,
donationsConfigurationCache,
pushServiceSocket::getDonationsConfiguration
);
}
public ServiceResponse<BankMandate> getBankMandate(Locale locale) {
return getCachedValue(
locale,
sepaBankMandateCache,
l -> pushServiceSocket.getBankMandate(l, "SEPA_DEBIT")
);
}
private <T> ServiceResponse<T> getCachedValue(Locale locale,
AtomicReference<CacheEntry<T>> cachedValueReference,
CacheEntryValueProducer<T> cacheEntryValueProducer
)
{
CacheEntry<T> cacheEntryOutsideLock = cachedValueReference.get();
if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) { if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) {
synchronized (this) { synchronized (this) {
CacheEntry cacheEntryInLock = donationsConfigurationCache.get(); CacheEntry<T> cacheEntryInLock = cachedValueReference.get();
if (isNewCacheEntryRequired(cacheEntryInLock, locale)) { if (isNewCacheEntryRequired(cacheEntryInLock, locale)) {
return wrapInServiceResponse(() -> { return wrapInServiceResponse(() -> {
DonationsConfiguration donationsConfiguration = pushServiceSocket.getDonationsConfiguration(locale); T value = cacheEntryValueProducer.produce(locale);
donationsConfigurationCache.set(new CacheEntry(donationsConfiguration, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale)); cachedValueReference.set(new CacheEntry<>(value, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale));
return new Pair<>(donationsConfiguration, 200); return new Pair<>(value, 200);
}); });
} else { } else {
return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.donationsConfiguration, 200)); return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.cachedValue, 200));
} }
} }
} else { } 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 * 400 - request error
* 409 - level requires a valid currency/amount combination that does not match * 409 - level requires a valid currency/amount combination that does not match
* *
* @param locale User locale for proper language presentation * @param locale User locale for proper language presentation
* @param currencyCode 3 letter currency code of the desired currency * @param currencyCode 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount * @param amount Stringified minimum precision amount
* @param level The badge level to purchase * @param level The badge level to purchase
* @param returnUrl The 'return' url after a successful login and confirmation * @param returnUrl The 'return' url after a successful login and confirmation
* @param cancelUrl The 'cancel' url for a cancelled 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 * @return Wrapped response with either an error code or a payment id and approval URL
*/ */
public ServiceResponse<PayPalCreatePaymentIntentResponse> createPayPalOneTimePaymentIntent(Locale locale, public ServiceResponse<PayPalCreatePaymentIntentResponse> createPayPalOneTimePaymentIntent(Locale locale,
String currencyCode, String currencyCode,
@@ -254,13 +276,13 @@ public class DonationsService {
* 400 - request error * 400 - request error
* 409 - level requires a valid currency/amount combination that does not match * 409 - level requires a valid currency/amount combination that does not match
* *
* @param currency 3 letter currency code of the desired currency * @param currency 3 letter currency code of the desired currency
* @param amount Stringified minimum precision amount * @param amount Stringified minimum precision amount
* @param level The badge level to purchase * @param level The badge level to purchase
* @param payerId Passed as a URL parameter back to returnUrl * @param payerId Passed as a URL parameter back to returnUrl
* @param paymentId 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 * @param paymentToken Passed as a URL parameter back to returnUrl
* @return Wrapped response with either an error code or a payment id * @return Wrapped response with either an error code or a payment id
*/ */
public ServiceResponse<PayPalConfirmPaymentIntentResponse> confirmPayPalOneTimePaymentIntent(String currency, public ServiceResponse<PayPalConfirmPaymentIntentResponse> confirmPayPalOneTimePaymentIntent(String currency,
String amount, String amount,
@@ -277,22 +299,23 @@ public class DonationsService {
/** /**
* Sets up a payment method via PayPal for recurring charges. * Sets up a payment method via PayPal for recurring charges.
* * <p>
* Response Codes * Response Codes
* 200 - success * 200 - success
* 403 - subscriberId password mismatches OR account authentication is present * 403 - subscriberId password mismatches OR account authentication is present
* 404 - subscriberId is not found or malformed * 404 - subscriberId is not found or malformed
* *
* @param locale User locale * @param locale User locale
* @param subscriberId User subscriber id * @param subscriberId User subscriber id
* @param returnUrl A success URL * @param returnUrl A success URL
* @param cancelUrl A cancel URL * @param cancelUrl A cancel URL
* @return A response with an approval url and token * @return A response with an approval url and token
*/ */
public ServiceResponse<PayPalCreatePaymentMethodResponse> createPayPalPaymentMethod(Locale locale, public ServiceResponse<PayPalCreatePaymentMethodResponse> createPayPalPaymentMethod(Locale locale,
SubscriberId subscriberId, SubscriberId subscriberId,
String returnUrl, String returnUrl,
String cancelUrl) { String cancelUrl)
{
return wrapInServiceResponse(() -> { return wrapInServiceResponse(() -> {
PayPalCreatePaymentMethodResponse response = pushServiceSocket.createPayPalPaymentMethod(locale, subscriberId.serialize(), returnUrl, cancelUrl); PayPalCreatePaymentMethodResponse response = pushServiceSocket.createPayPalPaymentMethod(locale, subscriberId.serialize(), returnUrl, cancelUrl);
return new Pair<>(response, 200); return new Pair<>(response, 200);
@@ -301,7 +324,7 @@ public class DonationsService {
/** /**
* Sets the given payment method as the default in PayPal * Sets the given payment method as the default in PayPal
* * <p>
* Response Codes * Response Codes
* 200 - success * 200 - success
* 403 - subscriberId password mismatches OR account authentication is present * 403 - subscriberId password mismatches OR account authentication is present
@@ -338,11 +361,15 @@ public class DonationsService {
} }
} }
private boolean isNewCacheEntryRequired(CacheEntry cacheEntry, Locale locale) { private <T> boolean isNewCacheEntryRequired(CacheEntry<T> cacheEntry, Locale locale) {
return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale); return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale);
} }
private interface Producer<T> { private interface Producer<T> {
Pair<T, Integer> produce() throws IOException; Pair<T, Integer> produce() throws IOException;
} }
interface CacheEntryValueProducer<T> {
T produce(Locale locale) throws IOException;
}
} }
@@ -11,4 +11,4 @@ import com.fasterxml.jackson.annotation.JsonProperty
/** /**
* Localized bank transfer mandate. * Localized bank transfer mandate.
*/ */
class BankMandate @JsonCreator constructor(@JsonProperty("mandate") mandate: String) class BankMandate @JsonCreator constructor(@JsonProperty("mandate") val mandate: String)