diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 214fddc6fe..50f27d597e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob; import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob; import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob; import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob; +import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.FontDownloaderJob; import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; @@ -224,6 +225,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr ApplicationDependencies.getMegaphoneRepository().onAppForegrounded(); ApplicationDependencies.getDeadlockDetector().start(); SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary(); + ExternalLaunchDonationJob.enqueueIfNecessary(); FcmFetchManager.onForeground(this); SignalExecutors.BOUNDED.execute(() -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index 9deebba694..46c3eb3f45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -264,6 +264,10 @@ class GiftFlowConfirmationFragment : findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest)) } + override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) { + error("Unsupported operation") + } + override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) { error("Unsupported operation") } @@ -281,6 +285,7 @@ class GiftFlowConfirmationFragment : } override fun onProcessorActionProcessed() = Unit + override fun onUserCancelledPaymentFlow() { findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index a9d8cd1a94..0e4b0cc4ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder +import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.events.ReminderUpdateEvent import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -50,6 +51,8 @@ class AppSettingsFragment : DSLSettingsFragment( private lateinit var reminderView: Stub override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(DonationCompletedDelegate(childFragmentManager, viewLifecycleOwner)) + super.onViewCreated(view, savedInstanceState) reminderView = ViewUtil.findStubById(view, R.id.reminder_stub) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt similarity index 93% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt index 7e8ab07b63..178dc3fc29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt @@ -19,7 +19,7 @@ import java.math.MathContext import java.util.Currency import kotlin.time.Duration.Companion.days -object PendingOneTimeDonationSerializer { +object DonationSerializationHelper { private val PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT = 14.days private val PENDING_ONE_TIME_NORMAL_TIMEOUT = 1.days @@ -35,7 +35,7 @@ object PendingOneTimeDonationSerializer { return (timestamp + timeout.inWholeMilliseconds) < System.currentTimeMillis() } - fun createProto( + fun createPendingOneTimeDonationProto( badge: Badge, paymentSourceType: PaymentSourceType, amount: FiatMoney @@ -60,7 +60,7 @@ object PendingOneTimeDonationSerializer { ) } - private fun DecimalValue.toBigDecimal(): BigDecimal { + fun DecimalValue.toBigDecimal(): BigDecimal { return BigDecimal( BigInteger(value_.toByteArray()), scale, @@ -75,7 +75,7 @@ object PendingOneTimeDonationSerializer { ) } - private fun BigDecimal.toDecimalValue(): DecimalValue { + fun BigDecimal.toDecimalValue(): DecimalValue { return DecimalValue( scale = scale(), precision = precision(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt index 7dcb7bb411..74c857dfba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt @@ -234,22 +234,28 @@ class MonthlyDonationRepository(private val donationsService: DonationsService) } private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single = Single.fromCallable { - Log.d(TAG, "Retrieving level update operation for $subscriptionLevel") - val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel) - if (levelUpdateOperation == null) { - val newOperation = LevelUpdateOperation( - idempotencyKey = IdempotencyKey.generate(), - level = subscriptionLevel - ) + getOrCreateLevelUpdateOperation(TAG, subscriptionLevel) + } - SignalStore.donationsValues().setLevelOperation(newOperation) - LevelUpdate.updateProcessingState(true) - Log.d(TAG, "Created a new operation for $subscriptionLevel") - newOperation - } else { - LevelUpdate.updateProcessingState(true) - Log.d(TAG, "Reusing operation for $subscriptionLevel") - levelUpdateOperation + companion object { + fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation { + Log.d(tag, "Retrieving level update operation for $subscriptionLevel") + val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel) + return if (levelUpdateOperation == null) { + val newOperation = LevelUpdateOperation( + idempotencyKey = IdempotencyKey.generate(), + level = subscriptionLevel + ) + + SignalStore.donationsValues().setLevelOperation(newOperation) + LevelUpdate.updateProcessingState(true) + Log.d(tag, "Created a new operation for $subscriptionLevel") + newOperation + } else { + LevelUpdate.updateProcessingState(true) + Log.d(tag, "Reusing operation for $subscriptionLevel") + levelUpdateOperation + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index 06533811dd..ab6252543a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -130,7 +130,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord) SignalStore.donationsValues().setPendingOneTimeDonation( - PendingOneTimeDonationSerializer.createProto( + DonationSerializationHelper.createPendingOneTimeDonationProto( gatewayRequest.badge, paymentSourceType, gatewayRequest.fiat diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index 6ce9b36186..fe60ae6503 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper @@ -47,7 +46,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION) - private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT) + private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient()) private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()) fun isGooglePayAvailable(): Completable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt new file mode 100644 index 0000000000..42b05f8ff0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.completed + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.signal.core.util.concurrent.LifecycleDisposable +import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Handles displaying the "Thank You" or "Donation completed" sheet when the user navigates to an appropriate screen. + * These sheets are one-shot. + */ +class DonationCompletedDelegate( + private val fragmentManager: FragmentManager, + private val lifecycleOwner: LifecycleOwner +) : DefaultLifecycleObserver { + + private val lifecycleDisposable = LifecycleDisposable().apply { + bindTo(lifecycleOwner) + } + + private val badgeRepository = DonationCompletedRepository() + + override fun onResume(owner: LifecycleOwner) { + val donations = SignalStore.donationsValues().consumeDonationCompletionList() + for (donation in donations) { + if (donation.isLongRunningPaymentMethod) { + DonationCompletedBottomSheet.show(fragmentManager, donation) + } else { + lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge -> + val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle() + val sheet = ThanksForYourSupportBottomSheetDialogFragment() + + sheet.arguments = args + sheet.show(fragmentManager, null) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 4f2db4a49a..afdca6323b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -142,12 +142,14 @@ class DonateToSignalFragment : findNavController().safeNavigate(navAction) } + is DonateToSignalAction.DisplayGatewaySelectorDialog -> { Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}") val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest) findNavController().safeNavigate(navAction) } + is DonateToSignalAction.CancelSubscription -> { findNavController().safeNavigate( DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( @@ -156,6 +158,7 @@ class DonateToSignalFragment : ) ) } + is DonateToSignalAction.UpdateSubscription -> { findNavController().safeNavigate( DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( @@ -418,6 +421,10 @@ class DonateToSignalFragment : findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest)) } + override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(gatewayRequest)) + } + override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) { findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index d05ad9a058..21fca103e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -207,9 +207,22 @@ class DonateToSignalViewModel( } private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) { - oneTimeDonationDisposables += SignalStore.donationsValues().observablePendingOneTimeDonation + val isOneTimeDonationInProgress: Observable = DonationRedemptionJobWatcher.watchOneTimeRedemption().map { + it.map { jobState -> + when (jobState) { + JobTracker.JobState.PENDING -> true + JobTracker.JobState.RUNNING -> true + else -> false + } + }.orElse(false) + }.distinctUntilChanged() + + val isOneTimeDonationPending: Observable = SignalStore.donationsValues().observablePendingOneTimeDonation .map { it.isPresent } .distinctUntilChanged() + + oneTimeDonationDisposables += Observable + .combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b } .subscribe { hasPendingOneTimeDonation -> store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index 8c599705d3..aeed2cad95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.fragments.requireListener import java.util.Currency @@ -133,6 +134,7 @@ class DonationCheckoutDelegate( if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) { Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show() } else { + SignalStore.donationsValues().removeDonationComplete(result.request.level) callback.onPaymentComplete(result.request) } } @@ -169,7 +171,11 @@ class DonationCheckoutDelegate( } private fun launchBankTransfer(gatewayResponse: GatewayResponse) { - callback.navigateToBankTransferMandate(gatewayResponse) + if (gatewayResponse.request.donateToSignalType != DonateToSignalType.MONTHLY && gatewayResponse.gateway == GatewayResponse.Gateway.IDEAL) { + callback.navigateToIdealDetailsFragment(gatewayResponse.request) + } else { + callback.navigateToBankTransferMandate(gatewayResponse) + } } private fun registerGooglePayCallback() { @@ -274,6 +280,12 @@ class DonationCheckoutDelegate( return } + if (throwable is DonationError.UserLaunchedExternalApplication) { + Log.d(TAG, "User launched an external application.", true) + + return + } + if (throwable is DonationError.BadgeRedemptionError.DonationPending) { Log.d(TAG, "Long-running donation is still pending.", true) errorHandlerCallback?.navigateToDonationPending(throwable.gatewayRequest) @@ -326,6 +338,7 @@ class DonationCheckoutDelegate( fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) + fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) fun onPaymentComplete(gatewayRequest: GatewayRequest) fun onProcessorActionProcessed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt index 298ebbc277..f78bf50846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt @@ -67,6 +67,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog DonationProcessorAction.CANCEL_SUBSCRIPTION -> { viewModel.cancelSubscription() } + else -> error("Unsupported action: ${args.action}") } } @@ -121,7 +122,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single { - return Single.create { emitter -> + return Single.create { emitter -> val listener = FragmentResultListener { _, bundle -> val result: PayPalConfirmationResult? = bundle.getParcelableCompat(PayPalConfirmationDialogFragment.REQUEST_KEY, PayPalConfirmationResult::class.java) if (result != null) { @@ -149,7 +150,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } private fun routeToMonthlyConfirmation(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single { - return Single.create { emitter -> + return Single.create { emitter -> val listener = FragmentResultListener { _, bundle -> val result: Boolean = bundle.getBoolean(PayPalConfirmationDialogFragment.REQUEST_KEY) if (result) { @@ -175,31 +176,4 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } }.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io()) } - - private fun displayCompleteOrderSheet(confirmationData: T): Single { - return Single.create { emitter -> - val listener = FragmentResultListener { _, bundle -> - val result: Boolean = bundle.getBoolean(PayPalCompleteOrderBottomSheet.REQUEST_KEY) - if (result) { - Log.d(TAG, "User confirmed order. Continuing...") - emitter.onSuccess(confirmationData) - } else { - emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource())) - } - } - - parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY) - parentFragmentManager.setFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY, this, listener) - - findNavController().safeNavigate( - PayPalPaymentInProgressFragmentDirections.actionPaypalPaymentInProgressFragmentToPaypalCompleteOrderBottomSheet(args.request) - ) - - emitter.setCancellable { - Log.d(TAG, "Clearing complete order result listener.") - parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY) - parentFragmentManager.clearFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY) - } - }.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io()) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/ExternalNavigationHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/ExternalNavigationHelper.kt new file mode 100644 index 0000000000..e8cb17d280 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/ExternalNavigationHelper.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R + +/** + * Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback + * or play store parameters to launch them into the market. + */ +object ExternalNavigationHelper { + + fun maybeLaunchExternalNavigationIntent(context: Context, webRequestUri: Uri?, launchIntent: (Intent) -> Unit): Boolean { + val url = webRequestUri ?: return false + if (url.scheme?.startsWith("http") == true) { + return false + } + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment) + .setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed) + .setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) } + .setNegativeButton(android.R.string.cancel, null) + .show() + + return true + } + + private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) { + val intent = Intent(Intent.ACTION_VIEW, url) + try { + launchIntent(intent) + } catch (e: ActivityNotFoundException) { + // Parses intent:// schema uris according to https://developer.chrome.com/docs/multidevice/android/intents/ + + if (url.scheme?.equals("intent") == true) { + val fragmentParts: Map = url.fragment + ?.split(";") + ?.associate { + val parts = it.split('=', limit = 2) + + if (parts.size > 1) { + parts[0] to parts[1] + } else { + parts[0] to null + } + } ?: emptyMap() + + val fallbackUri = fragmentParts["S.browser_fallback_url"]?.let { Uri.parse(it) } + + val packageId: String? = if (looksLikeAMarketLink(fallbackUri)) { + fallbackUri!!.getQueryParameter("id") + } else { + fragmentParts["package"] + } + + if (!packageId.isNullOrBlank()) { + try { + launchIntent( + Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=$packageId") + ) + ) + } catch (e: ActivityNotFoundException) { + toastOnActivityNotFound(context) + } + } else if (fallbackUri != null) { + try { + launchIntent( + Intent( + Intent.ACTION_VIEW, + fallbackUri + ) + ) + } catch (e: ActivityNotFoundException) { + toastOnActivityNotFound(context) + } + } + } + } + } + + private fun toastOnActivityNotFound(context: Context) { + Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show() + } + + private fun looksLikeAMarketLink(uri: Uri?): Boolean { + return uri != null && uri.host == "play.google.com" && uri.getQueryParameter("id") != null + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt new file mode 100644 index 0000000000..e91e7ebac5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.signal.donations.PaymentSourceType +import org.signal.donations.StripeIntentAccessor +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toBigDecimal +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.thoughtcrime.securesms.database.model.databaseprotos.ExternalLaunchTransactionState +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Encapsulates the data required to complete a pending external transaction + */ +@Parcelize +data class Stripe3DSData( + val stripeIntentAccessor: StripeIntentAccessor, + val gatewayRequest: GatewayRequest, + private val rawPaymentSourceType: String +) : Parcelable { + @IgnoredOnParcel + val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType) + + fun toProtoBytes(): ByteArray { + return ExternalLaunchTransactionState( + stripeIntentAccessor = ExternalLaunchTransactionState.StripeIntentAccessor( + type = when (stripeIntentAccessor.objectType) { + StripeIntentAccessor.ObjectType.NONE, StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT + StripeIntentAccessor.ObjectType.SETUP_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT + }, + intentId = stripeIntentAccessor.intentId, + intentClientSecret = stripeIntentAccessor.intentClientSecret + ), + gatewayRequest = ExternalLaunchTransactionState.GatewayRequest( + donateToSignalType = when (gatewayRequest.donateToSignalType) { + DonateToSignalType.ONE_TIME -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME + DonateToSignalType.MONTHLY -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY + DonateToSignalType.GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT + }, + badge = Badges.toDatabaseBadge(gatewayRequest.badge), + label = gatewayRequest.label, + price = gatewayRequest.price.toDecimalValue(), + currencyCode = gatewayRequest.currencyCode, + level = gatewayRequest.level, + recipient_id = gatewayRequest.recipientId.toLong(), + additionalMessage = gatewayRequest.additionalMessage ?: "" + ), + paymentSourceType = paymentSourceType.code + ).encode() + } + + companion object { + fun fromProtoBytes(byteArray: ByteArray, uiSessionKey: Long): Stripe3DSData { + val proto = ExternalLaunchTransactionState.ADAPTER.decode(byteArray) + return Stripe3DSData( + stripeIntentAccessor = StripeIntentAccessor( + objectType = when (proto.stripeIntentAccessor!!.type) { + ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT -> StripeIntentAccessor.ObjectType.PAYMENT_INTENT + ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT -> StripeIntentAccessor.ObjectType.SETUP_INTENT + }, + intentId = proto.stripeIntentAccessor.intentId, + intentClientSecret = proto.stripeIntentAccessor.intentClientSecret + ), + gatewayRequest = GatewayRequest( + uiSessionKey = uiSessionKey, + donateToSignalType = when (proto.gatewayRequest!!.donateToSignalType) { + ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> DonateToSignalType.MONTHLY + ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> DonateToSignalType.ONE_TIME + ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> DonateToSignalType.GIFT + }, + badge = Badges.fromDatabaseBadge(proto.gatewayRequest.badge!!), + label = proto.gatewayRequest.label, + price = proto.gatewayRequest.price!!.toBigDecimal(), + currencyCode = proto.gatewayRequest.currencyCode, + level = proto.gatewayRequest.level, + recipientId = RecipientId.from(proto.gatewayRequest.recipient_id), + additionalMessage = proto.gatewayRequest.additionalMessage.takeIf { it.isNotBlank() } + ), + rawPaymentSourceType = proto.paymentSourceType + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt index 9b009a98b6..9d1fab128a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt @@ -2,9 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s import android.annotation.SuppressLint import android.content.DialogInterface +import android.content.Intent import android.graphics.Bitmap import android.os.Bundle import android.view.View +import android.view.WindowManager +import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -18,6 +21,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.visible /** @@ -27,6 +31,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen companion object { const val REQUEST_KEY = "stripe_3ds_dialog_fragment" + const val LAUNCHED_EXTERNAL = "stripe_3ds_dialog_fragment.pending" } val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) { @@ -45,8 +50,14 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen @SuppressLint("SetJavaScriptEnabled") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + dialog!!.window!!.setFlags( + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + ) + binding.webView.webViewClient = Stripe3DSWebClient() binding.webView.settings.javaScriptEnabled = true + binding.webView.settings.domStorageEnabled = true binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE binding.webView.loadUrl(args.uri.toString()) @@ -66,8 +77,24 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen setFragmentResult(REQUEST_KEY, result ?: Bundle()) } + private fun handleLaunchExternal(intent: Intent) { + startActivity(intent) + + SignalStore.donationsValues().setPending3DSData(args.stripe3DSData) + + result = bundleOf( + LAUNCHED_EXTERNAL to true + ) + + dismissAllowingStateLoss() + } + private inner class Stripe3DSWebClient : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + return ExternalNavigationHelper.maybeLaunchExternalNavigationIntent(requireContext(), request?.url, this@Stripe3DSDialogFragment::handleLaunchExternal) + } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { binding.progress.visible = true } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt new file mode 100644 index 0000000000..5565e9e82f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import io.reactivex.rxjava3.core.Single +import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor + +fun interface StripeNextActionHandler { + fun handle( + action: StripeApi.Secure3DSAction, + stripe3DSData: Stripe3DSData + ): Single +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index c9005a8837..7e9eb261a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -116,7 +116,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } } - private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single { + private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single { return when (secure3dsAction) { is StripeApi.Secure3DSAction.NotNeeded -> { Log.d(TAG, "No 3DS action required.") @@ -124,19 +124,24 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } is StripeApi.Secure3DSAction.ConfirmRequired -> { Log.d(TAG, "3DS action required. Displaying dialog...") - Single.create { emitter -> + Single.create { emitter -> val listener = FragmentResultListener { _, bundle -> val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java) if (result != null) { emitter.onSuccess(result) } else { - emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource())) + val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false) + if (didLaunchExternal) { + emitter.onError(DonationError.UserLaunchedExternalApplication(args.request.donateToSignalType.toErrorSource())) + } else { + emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource())) + } } } parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener) - findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri)) + findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, stripe3DSData)) emitter.setCancellable { parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index bdd971fbce..a812b0d73d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -69,7 +69,7 @@ class StripePaymentInProgressViewModel( disposables.clear() } - fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single) { + fun processNewDonation(request: GatewayRequest, nextActionHandler: StripeNextActionHandler) { Log.d(TAG, "Proceeding with donation...", true) val errorSource = when (request.donateToSignalType) { @@ -93,18 +93,22 @@ class StripePaymentInProgressViewModel( PaymentSourceType.Stripe.GooglePay, Single.just(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() } ) + is StripePaymentData.CreditCard -> PaymentSourceProvider( PaymentSourceType.Stripe.CreditCard, stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() } ) + is StripePaymentData.SEPADebit -> PaymentSourceProvider( PaymentSourceType.Stripe.SEPADebit, stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() } ) + is StripePaymentData.IDEAL -> PaymentSourceProvider( PaymentSourceType.Stripe.IDEAL, stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() } ) + else -> error("This should never happen.") } } @@ -138,7 +142,7 @@ class StripePaymentInProgressViewModel( stripePaymentData = null } - private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single) { + private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) { val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId() val createAndConfirmSetupIntent: Single = paymentSourceProvider.paymentSource.flatMap { stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe) @@ -153,7 +157,14 @@ class StripePaymentInProgressViewModel( .andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary()) .andThen(createAndConfirmSetupIntent) .flatMap { secure3DSAction -> - nextActionHandler(secure3DSAction) + nextActionHandler.handle( + action = secure3DSAction, + Stripe3DSData( + secure3DSAction.stripeIntentAccessor, + request, + paymentSourceProvider.paymentSourceType.code + ) + ) .flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, paymentSourceProvider.paymentSourceType) } .map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! } } @@ -188,7 +199,7 @@ class StripePaymentInProgressViewModel( private fun proceedOneTime( request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, - nextActionHandler: (StripeApi.Secure3DSAction) -> Single + nextActionHandler: StripeNextActionHandler ) { Log.w(TAG, "Beginning one-time payment pipeline...", true) @@ -204,7 +215,16 @@ class StripePaymentInProgressViewModel( disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId) - .flatMap { nextActionHandler(it) } + .flatMap { + nextActionHandler.handle( + it, + Stripe3DSData( + it.stripeIntentAccessor, + request, + paymentSourceProvider.paymentSourceType.code + ) + ) + } .flatMap { stripeRepository.getStatusAndPaymentMethodId(it, paymentSourceProvider.paymentSourceType) } .flatMapCompletable { oneTimeDonationRepository.waitForOneTimeRedemption( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 764e320dba..6c47b6baca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -26,6 +26,12 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : */ class UserCancelledPaymentError(source: DonationErrorSource) : DonationError(source, Exception("User cancelled payment.")) + /** + * Utilized when the user launches into an external application while viewing the WebView. This should kick us back to the donations + * screen and await user processing. + */ + class UserLaunchedExternalApplication(source: DonationErrorSource) : DonationError(source, Exception("User launched external application.")) + /** * Gifting recipient validation errors, which occur before the user could be charged for a gift. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt index 11ca66792b..cc1c180b7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt @@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob +import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.keyvalue.SignalStore import java.util.Optional @@ -30,6 +31,10 @@ object DonationRedemptionJobWatcher { RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE } + val externalLaunchJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { + it.factoryKey == ExternalLaunchDonationJob.KEY && it.parameters.queue?.startsWith(queue) == true + } + val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState { it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue?.startsWith(queue) == true } @@ -43,7 +48,7 @@ object DonationRedemptionJobWatcher { it.factoryKey == receiptRequestJobKey && it.parameters.queue?.startsWith(queue) == true } - val jobState: JobTracker.JobState? = redemptionJobState ?: receiptJobState + val jobState: JobTracker.JobState? = externalLaunchJobState ?: redemptionJobState ?: receiptJobState if (redemptionType == RedemptionType.SUBSCRIPTION && jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) { Optional.of(JobTracker.JobState.FAILURE) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index d1e9d3d337..8b2481af11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import android.content.Intent +import android.os.Bundle import android.text.SpannableStringBuilder +import android.view.View import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels @@ -19,6 +21,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure @@ -63,6 +66,11 @@ class ManageDonationsFragment : } ) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(DonationCompletedDelegate(childFragmentManager, viewLifecycleOwner)) + super.onViewCreated(view, savedInstanceState) + } + override fun onResume() { super.onResume() viewModel.refresh() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt index 1f1104bb11..aa1262d19d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt @@ -11,7 +11,7 @@ import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.BadgeImageView import org.thoughtcrime.securesms.badges.Badges -import org.thoughtcrime.securesms.components.settings.app.subscription.PendingOneTimeDonationSerializer.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 8f82cb015c..f7c969788d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder; import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment; import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedBottomSheet; +import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate; import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation; import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; @@ -278,6 +279,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + getViewLifecycleOwner().getLifecycle().addObserver(new DonationCompletedDelegate(getParentFragmentManager(), getViewLifecycleOwner())); + lifecycleDisposable = new LifecycleDisposable(); lifecycleDisposable.bindTo(getViewLifecycleOwner()); @@ -523,11 +526,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(), getParentFragmentManager()); } - } else { - List donationCompletedList = SignalStore.donationsValues().consumeDonationCompletionList(); - for (DonationCompletedQueue.DonationCompleted donationCompleted : donationCompletedList) { - DonationCompletedBottomSheet.show(getParentFragmentManager(), donationCompleted); - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index cd26387f7e..b057dea4d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -499,6 +499,18 @@ public class JobManager implements ConstraintObserver.Notifier { return this; } + public Chain after(@NonNull Job job) { + return after(Collections.singletonList(job)); + } + + public Chain after(@NonNull List jobs) { + if (!jobs.isEmpty()) { + this.jobs.add(0, new ArrayList<>(jobs)); + } + + return this; + } + public void enqueue() { jobManager.enqueueChain(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index a519dc228d..79f57c1b3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -217,21 +217,19 @@ public class BoostReceiptRequestResponseJob extends BaseJob { receiptCredentialPresentation.serialize()) .serialize()); - enqueueDonationComplete(receiptCredentialPresentation.getReceiptLevel()); + enqueueDonationComplete(); } else { Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true); throw new RetryableException(); } } - private void enqueueDonationComplete(long receiptLevel) { - if (donationErrorSource != DonationErrorSource.GIFT || !isLongRunningDonationPaymentType) { + private void enqueueDonationComplete() { + if (donationErrorSource != DonationErrorSource.GIFT) { return; } - SignalStore.donationsValues().appendToDonationCompletionList( - new DonationCompletedQueue.DonationCompleted.Builder().level(receiptLevel).build() - ); + SignalStore.donationsValues().setPendingOneTimeDonation(null); } private void handleApplicationError(Context context, ServiceResponse response, @NonNull DonationErrorSource donationErrorSource) throws Exception { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index 5447bd84d2..90c6930cd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -307,13 +307,16 @@ public class DonationReceiptRedemptionJob extends BaseJob { return; } - if (uiSessionKey == -1L || !isLongRunningDonationPaymentType) { - Log.i(TAG, "Skipping donation complete sheet for state " + uiSessionKey + ", " + isLongRunningDonationPaymentType); + if (errorSource == DonationErrorSource.KEEP_ALIVE) { + Log.i(TAG, "Skipping donation complete sheet for subscription KEEP_ALIVE jobchain."); return; } SignalStore.donationsValues().appendToDonationCompletionList( - new DonationCompletedQueue.DonationCompleted.Builder().level(receiptLevel).build() + new DonationCompletedQueue.DonationCompleted.Builder() + .isLongRunningPaymentMethod(isLongRunningDonationPaymentType) + .level(receiptLevel) + .build() ); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt new file mode 100644 index 0000000000..1201d77558 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.jobs + +import io.reactivex.rxjava3.core.Single +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.signal.donations.PaymentSourceType +import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor +import org.signal.donations.json.StripeIntentStatus +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper +import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DonationReceiptRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper +import org.thoughtcrime.securesms.subscription.LevelUpdate +import org.thoughtcrime.securesms.util.Environment +import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.push.DonationProcessor +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Proceeds with an externally approved (say, in a bank app) donation + * and continues to process it. + */ +class ExternalLaunchDonationJob private constructor( + private val stripe3DSData: Stripe3DSData, + parameters: Parameters +) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { + + companion object { + const val KEY = "ExternalLaunchDonationJob" + + private val TAG = Log.tag(ExternalLaunchDonationJob::class.java) + + @JvmStatic + fun enqueueIfNecessary() { + val stripe3DSData = SignalStore.donationsValues().consumePending3DSData(-1L) ?: return + + val jobChain = when (stripe3DSData.gatewayRequest.donateToSignalType) { + DonateToSignalType.ONE_TIME -> BoostReceiptRequestResponseJob.createJobChainForBoost( + stripe3DSData.stripeIntentAccessor.intentId, + DonationProcessor.STRIPE, + -1L, + stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit + ) + DonateToSignalType.MONTHLY -> SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain( + -1L, + stripe3DSData.paymentSourceType.isBankTransfer + ) + DonateToSignalType.GIFT -> BoostReceiptRequestResponseJob.createJobChainForGift( + stripe3DSData.stripeIntentAccessor.intentId, + stripe3DSData.gatewayRequest.recipientId, + stripe3DSData.gatewayRequest.additionalMessage, + stripe3DSData.gatewayRequest.level, + DonationProcessor.STRIPE, + -1L, + false + ) + } + + val checkJob = ExternalLaunchDonationJob( + stripe3DSData, + Parameters.Builder() + .setQueue(if (stripe3DSData.gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY) DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE else DonationReceiptRedemptionJob.ONE_TIME_QUEUE) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toDays(1)) + .build() + ) + + jobChain.after(checkJob).enqueue() + } + } + + private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient()) + + override fun serialize(): ByteArray { + return stripe3DSData.toProtoBytes() + } + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + override fun onRun() { + when (stripe3DSData.stripeIntentAccessor.objectType) { + StripeIntentAccessor.ObjectType.NONE -> { + Log.w(TAG, "NONE type does not require confirmation. Failing Permanently.") + throw Exception() + } + StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> runForPaymentIntent() + StripeIntentAccessor.ObjectType.SETUP_INTENT -> runForSetupIntent() + } + } + + private fun runForPaymentIntent() { + Log.d(TAG, "Downloading payment intent...") + val stripePaymentIntent = stripeApi.getPaymentIntent(stripe3DSData.stripeIntentAccessor) + checkIntentStatus(stripePaymentIntent.status) + + Log.i(TAG, "Creating and inserting donation receipt record.", true) + val donationReceiptRecord = if (stripe3DSData.gatewayRequest.donateToSignalType == DonateToSignalType.ONE_TIME) { + DonationReceiptRecord.createForBoost(stripe3DSData.gatewayRequest.fiat) + } else { + DonationReceiptRecord.createForGift(stripe3DSData.gatewayRequest.fiat) + } + + SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord) + + Log.i(TAG, "Creating and inserting one-time pending donation.", true) + SignalStore.donationsValues().setPendingOneTimeDonation( + DonationSerializationHelper.createPendingOneTimeDonationProto( + stripe3DSData.gatewayRequest.badge, + stripe3DSData.paymentSourceType, + stripe3DSData.gatewayRequest.fiat + ) + ) + + Log.i(TAG, "Continuing job chain...", true) + } + + private fun runForSetupIntent() { + Log.d(TAG, "Downloading setup intent...") + val stripeSetupIntent = stripeApi.getSetupIntent(stripe3DSData.stripeIntentAccessor) + checkIntentStatus(stripeSetupIntent.status) + + val subscriber = SignalStore.donationsValues().requireSubscriber() + + Log.i(TAG, "Setting default payment method...", true) + val setPaymentMethodResponse = ApplicationDependencies.getDonationsService() + .setDefaultStripePaymentMethod(subscriber.subscriberId, stripeSetupIntent.paymentMethod!!) + + getResultOrThrow(setPaymentMethodResponse) + + Log.i(TAG, "Set default payment method via Signal service!", true) + Log.i(TAG, "Storing the subscription payment source type locally.", true) + SignalStore.donationsValues().setSubscriptionPaymentSourceType(stripe3DSData.paymentSourceType) + + val subscriptionLevel = stripe3DSData.gatewayRequest.level.toString() + + try { + val levelUpdateOperation = MonthlyDonationRepository.getOrCreateLevelUpdateOperation(TAG, subscriptionLevel) + Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true) + + val updateSubscriptionLevelResponse = ApplicationDependencies.getDonationsService().updateSubscriptionLevel( + subscriber.subscriberId, + subscriptionLevel, + subscriber.currencyCode, + levelUpdateOperation.idempotencyKey.serialize(), + SubscriptionReceiptRequestResponseJob.MUTEX + ) + + getResultOrThrow(updateSubscriptionLevelResponse, doOnApplicationError = { + SignalStore.donationsValues().clearLevelOperations() + }) + + if (updateSubscriptionLevelResponse.status in listOf(200, 204)) { + Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${updateSubscriptionLevelResponse.status}", true) + SignalStore.donationsValues().updateLocalStateForLocalSubscribe() + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } else { + error("Unexpected status code ${updateSubscriptionLevelResponse.status} without an application error or execution error.") + } + } finally { + LevelUpdate.updateProcessingState(false) + } + } + + private fun checkIntentStatus(stripeIntentStatus: StripeIntentStatus?) { + when (stripeIntentStatus) { + null, StripeIntentStatus.SUCCEEDED -> { + Log.i(TAG, "Stripe Intent is in the SUCCEEDED state, we can proceed.", true) + } + StripeIntentStatus.CANCELED -> { + Log.i(TAG, "Stripe Intent is cancelled, we cannot proceed.", true) + throw Exception("User cancelled payment.") + } + else -> { + Log.i(TAG, "Stripe Intent is still processing, retry later.", true) + throw RetryException() + } + } + } + + private fun getResultOrThrow( + serviceResponse: ServiceResponse, + doOnApplicationError: () -> Unit = {} + ): Result { + if (serviceResponse.result.isPresent) { + return serviceResponse.result.get() + } else if (serviceResponse.applicationError.isPresent) { + Log.w(TAG, "An application error was present. ${serviceResponse.status}", serviceResponse.applicationError.get(), true) + doOnApplicationError() + throw serviceResponse.applicationError.get() + } else if (serviceResponse.executionError.isPresent) { + Log.w(TAG, "An execution error was present. ${serviceResponse.status}", serviceResponse.executionError.get(), true) + throw serviceResponse.executionError.get() + } + + error("Should never get here.") + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is RetryException || e is IOException + } + + class RetryException : Exception() + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): ExternalLaunchDonationJob { + if (serializedData == null) { + error("Unexpected null value for serialized data") + } + + val stripe3DSData = Stripe3DSData.fromProtoBytes(serializedData, -1L) + + return ExternalLaunchDonationJob(stripe3DSData, parameters) + } + } + + override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { + error("Not needed, this job should not be creating intents.") + } + + override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single { + error("Not needed, this job should not be creating intents.") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 8d9ed910ea..655e60d36f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -166,6 +166,7 @@ public final class JobManagerFactories { put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory()); put(PnpInitializeDevicesJob.KEY, new PnpInitializeDevicesJob.Factory()); put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory()); + put(ExternalLaunchDonationJob.KEY, new ExternalLaunchDonationJob.Factory()); put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory()); put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); put(PushDistributionListSendJob.KEY, new PushDistributionListSendJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index d9b151fcd6..df1f8385c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -14,7 +14,8 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext import org.signal.libsignal.zkgroup.receipts.ReceiptSerial import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.components.settings.app.subscription.PendingOneTimeDonationSerializer.isExpired +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation @@ -124,6 +125,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign * the donation processing / donation pending state in the ManageDonationsFragment. */ private const val PENDING_ONE_TIME_DONATION = "pending.one.time.donation" + + /** + * Current pending 3DS data, set when the user launches an intent to an external source for + * completing a 3DS prompt or iDEAL prompt. + */ + private const val PENDING_3DS_DATA = "pending.3ds.data" } override fun onFirstEverAppLaunch() = Unit @@ -518,6 +525,15 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } + fun removeDonationComplete(level: Long) { + synchronized(this) { + val donationCompletionList = consumeDonationCompletionList() + donationCompletionList.filterNot { it.level == level }.forEach { + appendToDonationCompletionList(it) + } + } + } + fun getPendingOneTimeDonation(): PendingOneTimeDonation? = _pendingOneTimeDonation.takeUnless { it?.isExpired == true } fun setPendingOneTimeDonation(pendingOneTimeDonation: PendingOneTimeDonation?) { @@ -525,6 +541,27 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation)) } + fun consumePending3DSData(uiSessionKey: Long): Stripe3DSData? { + synchronized(this) { + val data = getBlob(PENDING_3DS_DATA, null)?.let { + Stripe3DSData.fromProtoBytes(it, uiSessionKey) + } + + setPending3DSData(null) + return data + } + } + + fun setPending3DSData(stripe3DSData: Stripe3DSData?) { + synchronized(this) { + if (stripe3DSData != null) { + putBlob(PENDING_3DS_DATA, stripe3DSData.toProtoBytes()) + } else { + remove(PENDING_3DS_DATA) + } + } + } + private fun generateRequestCredential(): ReceiptCredentialRequestContext { Log.d(TAG, "Generating request credentials context for token redemption...", true) val secureRandom = SecureRandom() diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 532c038b01..92c35229f0 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -301,8 +301,50 @@ message PendingOneTimeDonation { message DonationCompletedQueue { message DonationCompleted { - int64 level = 1; + int64 level = 1; + bool isLongRunningPaymentMethod = 2; } repeated DonationCompleted donationsCompleted = 1; } + +/** + * Contains the data necessary to complete a transaction after the + * user completes authorization externally. This helps prevent + * scenarios where the application dies while the user is confirming + * a transaction in their bank app. + */ +message ExternalLaunchTransactionState { + + message StripeIntentAccessor { + enum Type { + PAYMENT_INTENT = 0; + SETUP_INTENT = 1; + } + + Type type = 1; + string intentId = 2; + string intentClientSecret = 3; + } + + message GatewayRequest { + enum DonateToSignalType { + MONTHLY = 0; + ONE_TIME = 1; + GIFT = 2; + } + + DonateToSignalType donateToSignalType = 1; + BadgeList.Badge badge = 2; + string label = 3; + DecimalValue price = 4; + string currencyCode = 5; + int64 level = 6; + int64 recipient_id = 7; + string additionalMessage = 8; + } + + StripeIntentAccessor stripeIntentAccessor = 1; + GatewayRequest gatewayRequest = 2; + string paymentSourceType = 3; +} diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 4d45f80dff..aa7020bed5 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -27,4 +27,4 @@ message CallLogEventSendJobData { message CallLinkUpdateSendJobData { string callLinkRoomId = 1; -} \ No newline at end of file +} diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 273f63557a..a8801f6b40 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -48,6 +48,9 @@ + @@ -162,6 +165,11 @@ android:name="return_uri" app:argType="android.net.Uri" app:nullable="false" /> + + + + iDEAL + + Leave Signal to confirm payment? + Once this payment is confirmed, return to Signal to finish processing your donation. + ABN AMRO diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 44a29f4a2e..f46db5d3be 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -27,8 +27,7 @@ class StripeApi( private val configuration: Configuration, private val paymentIntentFetcher: PaymentIntentFetcher, private val setupIntentHelper: SetupIntentHelper, - private val okHttpClient: OkHttpClient, - private val userAgent: String + private val okHttpClient: OkHttpClient ) { private val objectMapper = jsonMapper { @@ -86,7 +85,7 @@ class StripeApi( getNextAction(response) } - Secure3DSAction.from(nextActionUri, returnUri, paymentMethodId) + Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId) } } @@ -134,7 +133,7 @@ class StripeApi( getNextAction(response) } - Secure3DSAction.from(nextActionUri, returnUri) + Secure3DSAction.from(nextActionUri, returnUri, paymentIntent) }.subscribeOn(Schedulers.io()) } @@ -620,21 +619,23 @@ class StripeApi( } sealed interface Secure3DSAction { - data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val paymentMethodId: String?) : Secure3DSAction - data class NotNeeded(override val paymentMethodId: String?) : Secure3DSAction + data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val stripeIntentAccessor: StripeIntentAccessor, override val paymentMethodId: String?) : Secure3DSAction + data class NotNeeded(override val paymentMethodId: String?, override val stripeIntentAccessor: StripeIntentAccessor) : Secure3DSAction val paymentMethodId: String? + val stripeIntentAccessor: StripeIntentAccessor companion object { fun from( uri: Uri, returnUri: Uri, + stripeIntentAccessor: StripeIntentAccessor, paymentMethodId: String? = null ): Secure3DSAction { return if (uri == Uri.EMPTY) { - NotNeeded(paymentMethodId) + NotNeeded(paymentMethodId, stripeIntentAccessor) } else { - ConfirmRequired(uri, returnUri, paymentMethodId) + ConfirmRequired(uri, returnUri, stripeIntentAccessor, paymentMethodId) } } }