Handle launch to external bank application.

This commit is contained in:
Alex Hart
2023-10-23 08:26:31 -04:00
committed by GitHub
parent e63137d293
commit d497ed4195
35 changed files with 788 additions and 89 deletions

View File

@@ -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(() -> {

View File

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

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -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
}

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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.")
}
}

View File

@@ -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());

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

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