Implement isLongRunning wiring for receipt redemption jobs.

This commit is contained in:
Alex Hart
2023-10-06 13:48:18 -04:00
committed by Cody Henthorne
parent 9cc020a2c7
commit 5ac363232f
24 changed files with 208 additions and 66 deletions

View File

@@ -124,16 +124,16 @@ class ViewReceivedGiftViewModel(
}
else -> {
Log.w(TAG, "Gift redemption job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION, false))
}
}
} else {
Log.w(TAG, "Timeout awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION, false))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Interrupted awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION, false))
}
}
}

View File

@@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L, false).enqueue()
}
private fun enqueueSubscriptionKeepAlive() {

View File

@@ -147,7 +147,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
}
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long, isLongRunning: Boolean): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
@@ -186,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, isLongRunning).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
@@ -206,16 +206,16 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION, isLongRunning))
}
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION, isLongRunning))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION, isLongRunning))
}
}
}.doOnError {

View File

@@ -112,7 +112,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
additionalMessage: String?,
badgeLevel: Long,
donationProcessor: DonationProcessor,
uiSessionKey: Long
uiSessionKey: Long,
isLongRunning: Boolean
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
@@ -132,9 +133,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey, isLongRunning)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey, isLongRunning)
}
chain.enqueue { _, jobState ->
@@ -157,16 +158,16 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
}
else -> {
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource, isLongRunning))
}
}
} else {
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource, isLongRunning))
}
} catch (e: InterruptedException) {
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource, isLongRunning))
}
}

View File

@@ -6,5 +6,5 @@ sealed class DonateToSignalAction {
data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest, val isLongRunning: Boolean) : DonateToSignalAction()
}

View File

@@ -73,6 +73,9 @@ data class DonateToSignalState(
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val isUpdateLongRunning: Boolean
get() = monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT
data class OneTimeDonationState(
val badge: Badge? = null,
val selectedCurrency: Currency = SignalStore.donationsValues().getOneTimeCurrency(),

View File

@@ -106,7 +106,7 @@ class DonateToSignalViewModel(
fun updateSubscription() {
val snapshot = store.state
if (snapshot.areFieldsEnabled) {
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot)))
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot), snapshot.isUpdateLongRunning))
}
}

View File

@@ -262,6 +262,12 @@ class DonationCheckoutDelegate(
return
}
if (throwable is DonationError.BadgeRedemptionError.DonationPending) {
Log.d(TAG, "Long-running donation is still pending.", true)
// TODO [sepa] Pop donation pending sheet.
return
}
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(),

View File

@@ -83,7 +83,7 @@ class PayPalPaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey, false))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -159,7 +159,8 @@ class PayPalPaymentInProgressViewModel(
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL,
uiSessionKey = request.uiSessionKey
uiSessionKey = request.uiSessionKey,
isLongRunning = false
)
}
.subscribeOn(Schedulers.io())
@@ -192,7 +193,7 @@ class PayPalPaymentInProgressViewModel(
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey, false))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)

View File

@@ -66,7 +66,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
}
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request)
viewModel.updateSubscription(args.request, args.isLongRunning)
}
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()

View File

@@ -135,7 +135,7 @@ class StripePaymentInProgressViewModel(
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey, paymentSourceProvider.paymentSourceType.isLongRunning)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -205,7 +205,8 @@ class StripePaymentInProgressViewModel(
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE,
uiSessionKey = request.uiSessionKey
uiSessionKey = request.uiSessionKey,
isLongRunning = paymentSource.type.isLongRunning
)
}
}.subscribeBy(
@@ -246,11 +247,10 @@ class StripePaymentInProgressViewModel(
)
}
fun updateSubscription(request: GatewayRequest) {
fun updateSubscription(request: GatewayRequest, isLongRunning: Boolean) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey, isLongRunning))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)

View File

@@ -88,6 +88,12 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
* Errors that can occur during the badge redemption process.
*/
sealed class BadgeRedemptionError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
/**
* Timeout elapsed while the user was waiting for badge redemption to complete for a long-running payment.
* This is not an indication that redemption failed, just that it could take a few days to process the payment.
*/
class DonationPending(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Long-running donation is still pending."))
/**
* Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that
* redemption failed, just that it is taking longer than we can reasonably show a spinner.
@@ -210,7 +216,13 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
@JvmStatic
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
fun timeoutWaitingForToken(source: DonationErrorSource, isLongRunning: Boolean): DonationError {
return if (isLongRunning) {
BadgeRedemptionError.DonationPending(source)
} else {
BadgeRedemptionError.TimeoutWaitingForTokenError(source)
}
}
@JvmStatic
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)

View File

@@ -34,6 +34,14 @@ class DonationErrorParams<V> private constructor(
negativeAction = null
)
// TODO [sepa] -- This is only used for the notification, and will be rare, but we should probably have better copy here.
is DonationError.BadgeRedemptionError.DonationPending -> DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,
positiveAction = callback.onOk(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams(
title = R.string.DonationsErrors__still_processing,
message = R.string.DonationsErrors__your_payment_is_still,

View File

@@ -33,7 +33,8 @@ object ActiveSubscriptionPreference {
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
val activeSubscription: ActiveSubscription.Subscription,
val onContactSupport: () -> Unit
val onContactSupport: () -> Unit,
val onPendingClick: (FiatMoney) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
@@ -57,6 +58,8 @@ object ActiveSubscriptionPreference {
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
override fun bind(model: Model) {
itemView.setOnClickListener(null)
badge.setBadge(model.subscription.badge)
title.text = context.getString(
@@ -72,6 +75,7 @@ object ActiveSubscriptionPreference {
when (model.redemptionState) {
ManageDonationsState.SubscriptionRedemptionState.NONE -> presentRenewalState(model)
ManageDonationsState.SubscriptionRedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model)
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
}
@@ -89,6 +93,13 @@ object ActiveSubscriptionPreference {
progress.visible = false
}
private fun presentPendingBankTransferState(model: Model) {
expiry.text = context.getString(R.string.MySupportPreference__payment_pending)
badge.alpha = 0.2f
progress.visible = true
itemView.setOnClickListener { model.onPendingClick(model.price) }
}
private fun presentInProgressState() {
expiry.text = context.getString(R.string.MySupportPreference__processing_transaction)
badge.alpha = 0.2f

View File

@@ -6,6 +6,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.dp
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
@@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadin
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SpanUtil
@@ -199,7 +201,10 @@ class ManageDonationsFragment :
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription
activeSubscription = activeSubscription,
onPendingClick = {
displayPendingDialog(it)
}
)
)
}
@@ -287,6 +292,22 @@ class ManageDonationsFragment :
)
}
private fun displayPendingDialog(fiatMoney: FiatMoney) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MySupportPreference__payment_pending)
.setMessage(
getString(
R.string.MySupportPreference__your_bank_transfer_of_s,
FiatMoneyUtil.format(resources, fiatMoney, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.setNegativeButton(R.string.MySupportPreference__learn_more) { _, _ ->
// TODO [sepa] Where this go?
}
.show()
}
override fun onMakeAMonthlyDonation() {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
}

View File

@@ -25,6 +25,7 @@ data class ManageDonationsState(
private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): SubscriptionRedemptionState? {
return when {
activeSubscription.isFailedPayment -> SubscriptionRedemptionState.FAILED
activeSubscription.isPendingBankTransfer -> SubscriptionRedemptionState.IS_PENDING_BANK_TRANSFER
activeSubscription.isInProgress -> SubscriptionRedemptionState.IN_PROGRESS
else -> null
}
@@ -40,6 +41,7 @@ data class ManageDonationsState(
enum class SubscriptionRedemptionState {
NONE,
IN_PROGRESS,
IS_PENDING_BANK_TRANSFER,
FAILED
}
}

View File

@@ -41,8 +41,9 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
private static final String BOOST_QUEUE = "BoostReceiptRedemption";
private static final String GIFT_QUEUE = "GiftReceiptRedemption";
private static final String BOOST_QUEUE = "BoostReceiptRedemption";
private static final String GIFT_QUEUE = "GiftReceiptRedemption";
private static final String LONG_RUNNING_SUFFIX = "__LongRunning";
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
@@ -50,6 +51,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String DATA_BADGE_LEVEL = "data.badge.level";
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
private ReceiptCredentialRequestContext requestContext;
@@ -58,14 +60,24 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private final long uiSessionKey;
private final boolean isLongRunning;
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor, long uiSessionKey) {
private static String resolveQueue(DonationErrorSource donationErrorSource, boolean isLongRunning) {
String baseQueue = donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE;
return isLongRunning ? baseQueue + LONG_RUNNING_SUFFIX : baseQueue;
}
private static long resolveLifespan(boolean isLongRunning) {
return isLongRunning ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1);
}
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor, long uiSessionKey, boolean isLongRunning) {
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setQueue(resolveQueue(donationErrorSource, isLongRunning))
.setLifespan(resolveLifespan(isLongRunning))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
null,
@@ -73,15 +85,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
donationErrorSource,
badgeLevel,
donationProcessor,
uiSessionKey
uiSessionKey,
isLongRunning
);
}
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
long uiSessionKey,
boolean isLongRunning)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey, isLongRunning);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -98,9 +112,10 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@Nullable String additionalMessage,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
long uiSessionKey,
boolean isLongRunning)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
@@ -115,7 +130,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey)
long uiSessionKey,
boolean isLongRunning)
{
super(parameters);
this.requestContext = requestContext;
@@ -124,6 +140,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.isLongRunning = isLongRunning;
}
@Override
@@ -132,7 +149,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunning);
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@@ -150,6 +168,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public void onFailure() {
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (isLongRunning) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
}
}
@Override
protected void onRun() throws Exception {
if (requestContext == null) {
@@ -283,15 +310,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunning = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View File

@@ -16,6 +16,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
@@ -131,11 +132,13 @@ public class SubscriptionKeepAliveJob extends BaseJob {
MultiDeviceSubscriptionSyncRequestJob.enqueue();
}
boolean isLongRunning = Objects.equals(activeSubscription.getActiveSubscription().getPaymentMethod(), ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT);
if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodConversionStarted()) {
Log.i(TAG, "Subscription end of period is after the conversion end of period. Storing it, generating a credential, and enqueuing the continuation job chain.", true);
SignalStore.donationsValues().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
SignalStore.donationsValues().refreshSubscriptionRequestCredential();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, isLongRunning).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) {
if (SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
Log.i(TAG, "We have not started a redemption, but do not have a request credential. Possible that the subscription changed.", true);
@@ -143,7 +146,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
}
Log.i(TAG, "We have a request credential and have not yet turned it into a redeemable token.", true);
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, isLongRunning).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()) {
if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) {
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);

View File

@@ -48,36 +48,39 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
private static final String DATA_IS_FOR_KEEP_ALIVE = "data.is.for.keep.alive";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
public static final Object MUTEX = new Object();
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private final boolean isLongRunning;
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey) {
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey, boolean isLongRunning) {
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxInstancesForQueue(1)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setLifespan(isLongRunning ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
subscriberId,
isForKeepAlive,
uiSessionKey
uiSessionKey,
isLongRunning
);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey) {
return createSubscriptionContinuationJobChain(false, uiSessionKey);
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, boolean isLongRunning) {
return createSubscriptionContinuationJobChain(false, uiSessionKey, isLongRunning);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey) {
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, boolean isLongRunning) {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey);
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, isLongRunning);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -92,19 +95,22 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey)
long uiSessionKey,
boolean isLongRunning)
{
super(parameters);
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.isLongRunning = isLongRunning;
}
@Override
public @Nullable byte[] serialize() {
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunning);
return builder.serialize();
}
@@ -436,6 +442,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunning = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
@@ -448,7 +455,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey);
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, isLongRunning);
}
}
}

View File

@@ -91,6 +91,12 @@
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<argument
android:name="is_long_running"
app:argType="boolean"
android:defaultValue="false" />
<action
android:id="@+id/action_stripePaymentInProgressFragment_to_stripe3dsDialogFragment"
app:destination="@id/stripe3dsDialogFragment" />

View File

@@ -95,6 +95,11 @@
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<argument
android:name="is_long_running"
app:argType="boolean"
android:defaultValue="false" />
<action
android:id="@+id/action_stripePaymentInProgressFragment_to_stripe3dsDialogFragment"
app:destination="@id/stripe3dsDialogFragment" />

View File

@@ -4761,6 +4761,12 @@
<!-- Displayed on "My Support" screen when user badge failed to be added to their account -->
<string name="MySupportPreference__couldnt_add_badge_s">Couldn\'t add badge. %1$s</string>
<string name="MySupportPreference__please_contact_support">Please contact support.</string>
<!-- Displayed as a subtitle on a row in the Manage Donations screen when payment for a donation is pending -->
<string name="MySupportPreference__payment_pending">Payment pending</string>
<!-- Displayed as a dialog message when clicking on a donation row that is pending. Placeholder is a formatted fiat amount -->
<string name="MySupportPreference__your_bank_transfer_of_s">Your bank transfer of %1$s is pending. Bank transfers usually take 1 business day to complete. </string>
<!-- Displayed in the pending help dialog, used to launch user to more details about bank transfers -->
<string name="MySupportPreference__learn_more">Learn more</string>
<!-- Title of dialog telling user they need to update signal as it expired -->
<string name="UpdateSignalExpiredDialog__title">Update Signal</string>

View File

@@ -2,6 +2,7 @@ package org.signal.donations
sealed class PaymentSourceType {
abstract val code: String
open val isLongRunning: Boolean = false
object Unknown : PaymentSourceType() {
override val code: String = Codes.UNKNOWN.code
@@ -11,10 +12,10 @@ sealed class PaymentSourceType {
override val code: String = Codes.PAY_PAL.code
}
sealed class Stripe(override val code: String, val paymentMethod: String) : PaymentSourceType() {
object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD")
object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD")
object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT")
sealed class Stripe(override val code: String, val paymentMethod: String, override val isLongRunning: Boolean) : PaymentSourceType() {
object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
}
private enum class Codes(val code: String) {

View File

@@ -13,6 +13,8 @@ import javax.annotation.Nullable;
public final class ActiveSubscription {
public static final String PAYMENT_METHOD_SEPA_DEBIT = "SEPA_DEBIT";
public static final ActiveSubscription EMPTY = new ActiveSubscription(null, null);
public enum Processor {
@@ -129,6 +131,10 @@ public final class ActiveSubscription {
return activeSubscription != null && activeSubscription.isActive();
}
public boolean isPendingBankTransfer() {
return activeSubscription != null && Objects.equals(activeSubscription.paymentMethod, PAYMENT_METHOD_SEPA_DEBIT) && activeSubscription.paymentPending;
}
public boolean isInProgress() {
return activeSubscription != null && !isActive() && !activeSubscription.isFailedPayment();
}
@@ -147,6 +153,8 @@ public final class ActiveSubscription {
private final boolean willCancelAtPeriodEnd;
private final String status;
private final Processor processor;
private final String paymentMethod;
private final boolean paymentPending;
@JsonCreator
public Subscription(@JsonProperty("level") int level,
@@ -157,7 +165,9 @@ public final class ActiveSubscription {
@JsonProperty("billingCycleAnchor") long billingCycleAnchor,
@JsonProperty("cancelAtPeriodEnd") boolean willCancelAtPeriodEnd,
@JsonProperty("status") String status,
@JsonProperty("processor") String processor)
@JsonProperty("processor") String processor,
@JsonProperty("paymentMethod") String paymentMethod,
@JsonProperty("paymentPending") boolean paymentPending)
{
this.level = level;
this.currency = currency;
@@ -168,6 +178,8 @@ public final class ActiveSubscription {
this.willCancelAtPeriodEnd = willCancelAtPeriodEnd;
this.status = status;
this.processor = Processor.fromCode(processor);
this.paymentMethod = paymentMethod;
this.paymentPending = paymentPending;
}
public int getLevel() {
@@ -222,8 +234,15 @@ public final class ActiveSubscription {
return processor;
}
public boolean isInProgress() {
return !isActive() && !Status.isPaymentFailed(getStatus());
public String getPaymentMethod() {
return paymentMethod;
}
/**
* @return Whether the latest invoice for the subscription is in a non-terminal state
*/
public boolean isPaymentPending() {
return paymentPending;
}
public boolean isFailedPayment() {
@@ -234,16 +253,18 @@ public final class ActiveSubscription {
return Status.getStatus(getStatus()) == Status.CANCELED;
}
@Override public boolean equals(Object o) {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Subscription that = (Subscription) o;
return level == that.level && endOfCurrentPeriod == that.endOfCurrentPeriod && isActive == that.isActive && billingCycleAnchor == that.billingCycleAnchor && willCancelAtPeriodEnd == that.willCancelAtPeriodEnd && currency
.equals(that.currency) && amount.equals(that.amount) && status.equals(that.status);
.equals(that.currency) && amount.equals(that.amount) && status.equals(that.status) && Objects.equals(paymentMethod, that.paymentMethod) && paymentPending == that.paymentPending;
}
@Override public int hashCode() {
return Objects.hash(level, currency, amount, endOfCurrentPeriod, isActive, billingCycleAnchor, willCancelAtPeriodEnd, status);
@Override
public int hashCode() {
return Objects.hash(level, currency, amount, endOfCurrentPeriod, isActive, billingCycleAnchor, willCancelAtPeriodEnd, status, paymentMethod, paymentPending);
}
}