mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 20:48:43 +00:00
Handle launch to external bank application.
This commit is contained in:
@@ -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(() -> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<ReminderView>
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -234,22 +234,28 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -207,9 +207,22 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
|
||||
oneTimeDonationDisposables += SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
val isOneTimeDonationInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
|
||||
it.map { jobState ->
|
||||
when (jobState) {
|
||||
JobTracker.JobState.PENDING -> true
|
||||
JobTracker.JobState.RUNNING -> true
|
||||
else -> false
|
||||
}
|
||||
}.orElse(false)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val isOneTimeDonationPending: Observable<Boolean> = 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)) }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PayPalConfirmationResult> {
|
||||
return Single.create<PayPalConfirmationResult> { 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<PayPalPaymentMethodId> {
|
||||
return Single.create<PayPalPaymentMethodId> { 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 <T : Any> displayCompleteOrderSheet(confirmationData: T): Single<T> {
|
||||
return Single.create<T> { 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String?> = 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<StripeIntentAccessor>
|
||||
}
|
||||
@@ -116,7 +116,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single<StripeIntentAccessor> {
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single<StripeIntentAccessor> {
|
||||
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<StripeIntentAccessor> { 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)
|
||||
|
||||
@@ -69,7 +69,7 @@ class StripePaymentInProgressViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
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<StripeApi.PaymentSource>(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<StripeIntentAccessor>) {
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
|
||||
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = 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<StripeIntentAccessor>
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DonationCompletedQueue.DonationCompleted> donationCompletedList = SignalStore.donationsValues().consumeDonationCompletionList();
|
||||
for (DonationCompletedQueue.DonationCompleted donationCompleted : donationCompletedList) {
|
||||
DonationCompletedBottomSheet.show(getParentFragmentManager(), donationCompleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<? extends Job> jobs) {
|
||||
if (!jobs.isEmpty()) {
|
||||
this.jobs.add(0, new ArrayList<>(jobs));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public void enqueue() {
|
||||
jobManager.enqueueChain(this);
|
||||
}
|
||||
|
||||
@@ -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<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Result> getResultOrThrow(
|
||||
serviceResponse: ServiceResponse<Result>,
|
||||
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<ExternalLaunchDonationJob> {
|
||||
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<StripeIntentAccessor> {
|
||||
error("Not needed, this job should not be creating intents.")
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
error("Not needed, this job should not be creating intents.")
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_donationPendingBottomSheet"
|
||||
app:destination="@id/donationPendingBottomSheet" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_idealTransferDetailsFragment"
|
||||
app:destination="@id/idealTransferDetailsFragment" />
|
||||
|
||||
</fragment>
|
||||
|
||||
@@ -162,6 +165,11 @@
|
||||
android:name="return_uri"
|
||||
app:argType="android.net.Uri"
|
||||
app:nullable="false" />
|
||||
|
||||
<argument
|
||||
android:name="stripe3DSData"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData"
|
||||
app:nullable="false" />
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
|
||||
@@ -136,6 +136,11 @@
|
||||
android:name="return_uri"
|
||||
app:argType="android.net.Uri"
|
||||
app:nullable="false" />
|
||||
|
||||
<argument
|
||||
android:name="stripe3DSData"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData"
|
||||
app:nullable="false" />
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
|
||||
@@ -5846,6 +5846,10 @@
|
||||
<!-- Button label for paying with iDEAL -->
|
||||
<string name="GatewaySelectorBottomSheet__ideal">iDEAL</string>
|
||||
|
||||
<!-- Dialog title for launching external intent -->
|
||||
<string name="ExternalNavigationHelper__leave_signal_to_confirm_payment">Leave Signal to confirm payment?</string>
|
||||
<string name="ExternalNavigationHelper__once_this_payment_is_confirmed">Once this payment is confirmed, return to Signal to finish processing your donation.</string>
|
||||
|
||||
<!-- IdealBank -->
|
||||
<!-- iDEAL bank name -->
|
||||
<string name="IdealBank__abn_amro">ABN AMRO</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user