Remove old donation jobs.

This commit is contained in:
Alex Hart
2024-11-14 09:51:23 -04:00
committed by Greyson Parrelli
parent ed1348c20d
commit e82dfea93c
10 changed files with 49 additions and 2152 deletions

View File

@@ -36,7 +36,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -522,21 +521,13 @@ object InAppPaymentsRepository {
*/
@WorkerThread
fun hasPendingDonation(): Boolean {
return SignalDatabase.inAppPayments.hasPendingDonation() || DonationRedemptionJobWatcher.hasPendingRedemptionJob()
return SignalDatabase.inAppPayments.hasPendingDonation()
}
/**
* Emits a stream of status updates for donations of the given type. Only One-time donations and recurring donations are currently supported.
*/
fun observeInAppPaymentRedemption(type: InAppPaymentType): Observable<DonationRedemptionJobStatus> {
val jobStatusObservable: Observable<DonationRedemptionJobStatus> = when (type) {
InAppPaymentType.UNKNOWN -> Observable.empty()
InAppPaymentType.ONE_TIME_GIFT -> Observable.empty()
InAppPaymentType.ONE_TIME_DONATION -> DonationRedemptionJobWatcher.watchOneTimeRedemption()
InAppPaymentType.RECURRING_DONATION -> DonationRedemptionJobWatcher.watchSubscriptionRedemption()
InAppPaymentType.RECURRING_BACKUP -> Observable.empty()
}
val fromDatabase: Observable<DonationRedemptionJobStatus> = Observable.create { emitter ->
val observer = InAppPaymentObserver {
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
@@ -547,7 +538,7 @@ object InAppPaymentsRepository {
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
emitter.setCancellable { AppDependencies.databaseObserver.unregisterObserver(observer) }
}.switchMap { inAppPaymentOptional ->
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap jobStatusObservable
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap Observable.just(DonationRedemptionJobStatus.None)
val value = when (inAppPayment.state) {
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
@@ -576,15 +567,7 @@ object InAppPaymentsRepository {
Observable.just(value)
}
return fromDatabase
.switchMap {
if (it == DonationRedemptionJobStatus.None) {
jobStatusObservable
} else {
Observable.just(it)
}
}
.distinctUntilChanged()
return fromDatabase.distinctUntilChanged()
}
fun scheduleSyncForAccountRecordChange() {

View File

@@ -1,141 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
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.concurrent.TimeUnit
/**
* Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions or one time payments.
*
* @deprecated This object is deprecated and will be removed once we are sure all jobs have drained.
*/
object DonationRedemptionJobWatcher {
enum class RedemptionType {
SUBSCRIPTION,
ONE_TIME
}
@WorkerThread
fun hasPendingRedemptionJob(): Boolean {
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION).isInProgress() || getDonationRedemptionJobStatus(RedemptionType.ONE_TIME).isInProgress()
}
fun watchSubscriptionRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.SUBSCRIPTION)
@JvmStatic
@WorkerThread
fun getSubscriptionRedemptionJobStatus(): DonationRedemptionJobStatus {
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION)
}
fun watchOneTimeRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.ONE_TIME)
private fun watch(redemptionType: RedemptionType): Observable<DonationRedemptionJobStatus> {
return Observable
.interval(0, 5, TimeUnit.SECONDS)
.map {
getDonationRedemptionJobStatus(redemptionType)
}
.distinctUntilChanged()
}
private fun getDonationRedemptionJobStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
val queue = when (redemptionType) {
RedemptionType.SUBSCRIPTION -> DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE
}
val donationJobSpecs = AppDependencies
.jobManager
.find { it.queueKey?.startsWith(queue) == true }
.sortedBy { it.createTime }
val externalLaunchJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == ExternalLaunchDonationJob.KEY
}
val receiptRequestJobKey = when (redemptionType) {
RedemptionType.SUBSCRIPTION -> SubscriptionReceiptRequestResponseJob.KEY
RedemptionType.ONE_TIME -> BoostReceiptRequestResponseJob.KEY
}
val receiptJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == receiptRequestJobKey
}
val redemptionJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == DonationReceiptRedemptionJob.KEY
}
val jobSpec: JobSpec? = externalLaunchJobSpec ?: redemptionJobSpec ?: receiptJobSpec
return if (redemptionType == RedemptionType.SUBSCRIPTION && jobSpec == null && SignalStore.inAppPayments.getSubscriptionRedemptionFailed()) {
DonationRedemptionJobStatus.FailedSubscription
} else {
jobSpec?.toDonationRedemptionStatus(redemptionType) ?: DonationRedemptionJobStatus.None
}
}
private fun JobSpec.toDonationRedemptionStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
return when (factoryKey) {
ExternalLaunchDonationJob.KEY -> {
val stripe3DSData = ExternalLaunchDonationJob.Factory.parseSerializedData(serializedData!!)
DonationRedemptionJobStatus.PendingExternalVerification(
pendingOneTimeDonation = pendingOneTimeDonation(redemptionType, stripe3DSData),
nonVerifiedMonthlyDonation = nonVerifiedMonthlyDonation(redemptionType, stripe3DSData)
)
}
SubscriptionReceiptRequestResponseJob.KEY,
BoostReceiptRequestResponseJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRequest
DonationReceiptRedemptionJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRedemption
else -> {
DonationRedemptionJobStatus.None
}
}
}
private fun JobSpec.pendingOneTimeDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): PendingOneTimeDonation? {
if (redemptionType != RedemptionType.ONE_TIME) {
return null
}
return DonationSerializationHelper.createPendingOneTimeDonationProto(
badge = Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
paymentSourceType = stripe3DSData.paymentSourceType,
amount = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney()
).copy(
timestamp = createTime,
pendingVerification = true,
checkedVerification = runAttempt > 0
)
}
private fun JobSpec.nonVerifiedMonthlyDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): NonVerifiedMonthlyDonation? {
if (redemptionType != RedemptionType.SUBSCRIPTION) {
return null
}
return NonVerifiedMonthlyDonation(
timestamp = createTime,
price = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney(),
level = stripe3DSData.inAppPayment.data.level.toInt(),
checkedVerification = runAttempt > 0
)
}
}

View File

@@ -1,446 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.signal.donations.StripeDeclineCode;
import org.signal.donations.StripeFailureCode;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.DonationProcessor;
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentReceiptCredentialError;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
/**
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
* we get a response.
*
* @deprecated Replaced with InAppPaymentOneTimeContextJob
*/
@Deprecated
public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String TAG = Log.tag(BoostReceiptRequestResponseJob.class);
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
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";
private static final String DATA_ERROR_SOURCE = "data.error.source";
private static final String DATA_BADGE_LEVEL = "data.badge.level";
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private static final String DATA_TERMINAL_DONATION = "data.terminal.donation";
private ReceiptCredentialRequestContext requestContext;
private TerminalDonationQueue.TerminalDonation terminalDonation;
private final DonationErrorSource donationErrorSource;
private final String paymentIntentId;
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private final long uiSessionKey;
private static String resolveQueue(DonationErrorSource donationErrorSource, boolean isLongRunning) {
String baseQueue = donationErrorSource == DonationErrorSource.ONE_TIME ? BOOST_QUEUE : GIFT_QUEUE;
return isLongRunning ? baseQueue + LONG_RUNNING_SUFFIX : baseQueue;
}
private static long resolveLifespan(boolean isLongRunning) {
return isLongRunning ? TimeUnit.DAYS.toMillis(30) : TimeUnit.DAYS.toMillis(1);
}
private static BoostReceiptRequestResponseJob createJob(@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(resolveQueue(donationErrorSource, terminalDonation.isLongRunningPaymentMethod))
.setLifespan(resolveLifespan(terminalDonation.isLongRunningPaymentMethod))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
null,
paymentIntentId,
donationErrorSource,
badgeLevel,
donationProcessor,
uiSessionKey,
terminalDonation
);
}
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.ONE_TIME, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey, terminalDonation);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey, terminalDonation.isLongRunningPaymentMethod);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
return AppDependencies.getJobManager()
.startChain(requestReceiptJob)
.then(redeemReceiptJob)
.then(refreshOwnProfileJob)
.then(multiDeviceProfileContentUpdateJob);
}
public static JobManager.Chain createJobChainForGift(@NonNull String paymentIntentId,
@NonNull RecipientId recipientId,
@Nullable String additionalMessage,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey, terminalDonation);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
return AppDependencies.getJobManager()
.startChain(requestReceiptJob)
.then(giftSendJob);
}
private BoostReceiptRequestResponseJob(@NonNull Parameters parameters,
@Nullable ReceiptCredentialRequestContext requestContext,
@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
super(parameters);
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.terminalDonation = terminalDonation;
}
@Override
public @Nullable byte[] serialize() {
JsonJobData.Builder builder = new JsonJobData.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBlobAsString(DATA_TERMINAL_DONATION, terminalDonation.encode());
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
}
return builder.serialize();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
if (terminalDonation.error != null) {
SignalStore.inAppPayments().appendToTerminalDonationQueue(terminalDonation);
} else {
Log.w(TAG, "Job is in terminal state without an error on TerminalDonation.");
}
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (terminalDonation.isLongRunningPaymentMethod) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
}
}
@Override
protected void onRun() throws Exception {
if (requestContext == null) {
Log.d(TAG, "Creating request context..");
SecureRandom secureRandom = new SecureRandom();
byte[] randomBytes = new byte[ReceiptSerial.SIZE];
secureRandom.nextBytes(randomBytes);
ReceiptSerial receiptSerial = new ReceiptSerial(randomBytes);
ClientZkReceiptOperations operations = AppDependencies.getClientZkReceiptOperations();
requestContext = operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial);
} else {
Log.d(TAG, "Reusing request context from previous run", true);
}
Log.d(TAG, "Submitting credential to server", true);
ServiceResponse<ReceiptCredentialResponse> response = AppDependencies.getDonationsService()
.submitBoostReceiptCredentialRequestSync(paymentIntentId, requestContext.getRequest(), donationProcessor);
if (response.getApplicationError().isPresent()) {
handleApplicationError(context, response, donationErrorSource);
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
if (!isCredentialValid(receiptCredential)) {
DonationError.routeBackgroundError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
setPendingOneTimeDonationGenericRedemptionError(-1);
throw new IOException("Could not validate receipt credential");
}
Log.d(TAG, "Validated credential. Handing off to next job.", true);
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
setOutputData(new JsonJobData.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())
.putBlobAsString(DonationReceiptRedemptionJob.INPUT_TERMINAL_DONATION, terminalDonation.encode())
.serialize());
if (donationErrorSource == DonationErrorSource.GIFT) {
SignalStore.inAppPayments().setPendingOneTimeDonation(null);
}
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true);
throw new RetryableException();
}
}
/**
* Sets the pending one-time donation error according to the status code.
*/
private void setPendingOneTimeDonationGenericRedemptionError(int statusCode) {
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(statusCode == 402
? DonationErrorValue.Type.PAYMENT
: DonationErrorValue.Type.REDEMPTION)
.code(Integer.toString(statusCode))
.build();
SignalStore.inAppPayments().setPendingOneTimeDonationError(
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.build();
}
/**
* Sets the pending one-time donation error according to the given charge failure.
*/
private void setPendingOneTimeDonationChargeFailureError(@NonNull ActiveSubscription.ChargeFailure chargeFailure) {
final DonationErrorValue.Type type;
final String code;
if (donationProcessor == DonationProcessor.PAYPAL) {
code = chargeFailure.getCode();
type = DonationErrorValue.Type.PROCESSOR_CODE;
} else {
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
StripeFailureCode failureCode = StripeFailureCode.Companion.getFromCode(chargeFailure.getCode());
if (failureCode.isKnown()) {
code = failureCode.toString();
type = DonationErrorValue.Type.FAILURE_CODE;
} else if (declineCode.isKnown()) {
code = declineCode.toString();
type = DonationErrorValue.Type.DECLINE_CODE;
} else {
code = chargeFailure.getCode();
type = DonationErrorValue.Type.PROCESSOR_CODE;
}
}
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(type)
.code(code)
.build();
SignalStore.inAppPayments().setPendingOneTimeDonationError(
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.build();
}
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) {
case 204:
Log.w(TAG, "User payment not be completed yet.", applicationException, true);
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
throw new Exception(applicationException);
case 402:
Log.w(TAG, "User payment failed.", applicationException, true);
DonationError.routeBackgroundError(context, DonationError.genericPaymentFailure(donationErrorSource), terminalDonation.isLongRunningPaymentMethod);
if (applicationException instanceof InAppPaymentReceiptCredentialError) {
setPendingOneTimeDonationChargeFailureError(((InAppPaymentReceiptCredentialError) applicationException).getChargeFailure());
} else {
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
}
throw new Exception(applicationException);
case 409:
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
throw new Exception(applicationException);
default:
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
throw new RetryableException();
}
}
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
ClientZkReceiptOperations operations = AppDependencies.getClientZkReceiptOperations();
try {
return operations.createReceiptCredentialPresentation(receiptCredential);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}
}
private ReceiptCredential getReceiptCredential(@NonNull ReceiptCredentialResponse response) throws RetryableException {
ClientZkReceiptOperations operations = AppDependencies.getClientZkReceiptOperations();
try {
return operations.receiveReceiptCredential(requestContext, response);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}
}
/**
* Checks that the generated Receipt Credential has the following characteristics
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
* - expiration time should have the following characteristics:
* - expiration_time mod 86400 == 0
* - expiration_time is between now and 90 days from now
*/
private boolean isCredentialValid(@NonNull ReceiptCredential receiptCredential) {
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == badgeLevel;
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
boolean isExpirationWithinMax = receiptCredential.getReceiptExpirationTime() <= maxExpirationTime;
Log.d(TAG, "Credential validation: isCorrectLevel(" + isCorrectLevel + " actual: " + receiptCredential.getReceiptLevel() + ", expected: " + badgeLevel +
") isExpiration86400(" + isExpiration86400 +
") isExpirationInTheFuture(" + isExpirationInTheFuture +
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
return isCorrectLevel && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax;
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
@VisibleForTesting final static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
@Override
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.ONE_TIME.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
byte[] rawTerminalDonation = data.getStringAsBlob(DATA_TERMINAL_DONATION);
TerminalDonationQueue.TerminalDonation terminalDonation = null;
if (rawTerminalDonation != null) {
try {
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} catch (IOException e) {
Log.e(TAG, "Failed to parse terminal donation. Generating a default.");
}
}
if (terminalDonation == null) {
terminalDonation = new TerminalDonationQueue.TerminalDonation(
-1,
false,
null,
ByteString.EMPTY
);
}
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, terminalDonation);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, terminalDonation);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);
}
}
}
}

View File

@@ -1,358 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.locks.Lock;
/**
* Job to redeem a verified donation receipt. It is up to the Job prior in the chain to specify a valid
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
*
* @deprecated Replaced with InAppPaymentRedemptionJob
*/
@Deprecated
public class DonationReceiptRedemptionJob extends BaseJob {
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
private static final long NO_ID = -1L;
private static final int MAX_RETRIES = 1500;
public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption";
public static final String ONE_TIME_QUEUE = "BoostReceiptRedemption";
public static final String KEY = "DonationReceiptRedemptionJob";
private static final String LONG_RUNNING_QUEUE_SUFFIX = "__LONG_RUNNING";
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
public static final String INPUT_TERMINAL_DONATION = "data.terminal.donation";
public static final String INPUT_KEEP_ALIVE_409 = "data.keep.alive.409";
public static final String DATA_ERROR_SOURCE = "data.error.source";
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary";
public static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private final long giftMessageId;
private final boolean makePrimary;
private final DonationErrorSource errorSource;
private final long uiSessionKey;
private TerminalDonationQueue.TerminalDonation terminalDonation;
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
errorSource,
uiSessionKey,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(SUBSCRIPTION_QUEUE + (isLongRunningDonationPaymentType ? LONG_RUNNING_QUEUE_SUFFIX : ""))
.setMaxAttempts(MAX_RETRIES)
.setLifespan(Parameters.IMMORTAL)
.build());
}
public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
DonationErrorSource.ONE_TIME,
uiSessionKey,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(ONE_TIME_QUEUE + (isLongRunningDonationPaymentType ? LONG_RUNNING_QUEUE_SUFFIX : ""))
.setMaxAttempts(MAX_RETRIES)
.setLifespan(Parameters.IMMORTAL)
.build());
}
public static JobManager.Chain createJobChainForKeepAlive() {
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L, false);
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
return AppDependencies.getJobManager()
.startChain(redemptionJob)
.then(refreshOwnProfileJob)
.then(multiDeviceProfileContentUpdateJob);
}
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
super(parameters);
this.giftMessageId = giftMessageId;
this.makePrimary = primary;
this.errorSource = errorSource;
this.uiSessionKey = uiSessionKey;
}
@Override
public @Nullable byte[] serialize() {
return new JsonJobData.Builder()
.putString(DATA_ERROR_SOURCE, errorSource.serialize())
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.serialize();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
if (getInputData() == null) {
Log.d(TAG, "No input data, assuming upstream job in chain failed and properly set error state. Failing without side effects.");
return;
}
if (isForSubscription()) {
Log.d(TAG, "Marking subscription failure", true);
SignalStore.inAppPayments().markSubscriptionRedemptionFailed();
MultiDeviceSubscriptionSyncRequestJob.enqueue();
} else if (giftMessageId != NO_ID) {
SignalDatabase.messages().markGiftRedemptionFailed(giftMessageId);
}
if (terminalDonation != null) {
SignalStore.inAppPayments().appendToTerminalDonationQueue(terminalDonation);
}
}
@Override
public void onAdded() {
if (giftMessageId != NO_ID) {
SignalDatabase.messages().markGiftRedemptionStarted(giftMessageId);
}
}
@Override
protected void onRun() throws Exception {
if (isForSubscription()) {
Lock lock = InAppPaymentSubscriberRecord.Type.DONATION.getLock();
lock.lock();
try {
doRun();
} finally {
lock.unlock();
}
} else {
doRun();
}
}
private void doRun() throws Exception {
JsonJobData inputData = getInputData() != null ? JsonJobData.deserialize(getInputData()) : null;
boolean isKeepAlive409 = inputData != null && inputData.getBooleanOrDefault(INPUT_KEEP_ALIVE_409, false);
if (isKeepAlive409) {
Log.d(TAG, "Keep-Alive redemption job hit a 409. Exiting.", true);
return;
}
ReceiptCredentialPresentation presentation = getPresentation();
if (presentation == null) {
Log.d(TAG, "No presentation available. Exiting.", true);
return;
}
byte[] rawTerminalDonation = inputData != null ? inputData.getStringAsBlob(INPUT_TERMINAL_DONATION) : null;
if (rawTerminalDonation != null) {
Log.d(TAG, "Retrieved terminal donation information from input data.");
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} else {
Log.d(TAG, "Input data does not contain terminal donation data. Creating one with sane defaults.");
terminalDonation = new TerminalDonationQueue.TerminalDonation.Builder()
.level(presentation.getReceiptLevel())
.build();
}
Log.d(TAG, "Attempting to redeem token... isForSubscription: " + isForSubscription(), true);
ServiceResponse<EmptyResponse> response = AppDependencies.getDonationsService()
.redeemDonationReceipt(presentation,
SignalStore.inAppPayments().getDisplayBadgesOnProfile(),
makePrimary);
if (response.getApplicationError().isPresent()) {
if (response.getStatus() >= 500) {
Log.w(TAG, "Encountered a server exception " + response.getStatus(), response.getApplicationError().get(), true);
throw new RetryableException();
} else {
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(errorSource));
if (isForOneTimeDonation()) {
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(DonationErrorValue.Type.REDEMPTION)
.code(Integer.toString(response.getStatus()))
.build();
SignalStore.inAppPayments().setPendingOneTimeDonationError(
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.build();
}
throw new IOException(response.getApplicationError().get());
}
} else if (response.getExecutionError().isPresent()) {
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get(), true);
throw new RetryableException();
}
Log.i(TAG, "Successfully redeemed token with response code " + response.getStatus() + "... isForSubscription: " + isForSubscription(), true);
enqueueDonationComplete();
if (isForSubscription()) {
Log.d(TAG, "Clearing subscription failure", true);
SignalStore.inAppPayments().clearSubscriptionRedemptionFailed();
Log.i(TAG, "Recording end of period from active subscription", true);
SignalStore.inAppPayments()
.setSubscriptionEndOfPeriodRedeemed(SignalStore.inAppPayments()
.getSubscriptionEndOfPeriodRedemptionStarted());
SignalStore.inAppPayments().clearSubscriptionReceiptCredential();
} else if (giftMessageId != NO_ID) {
Log.d(TAG, "Marking gift redemption completed for " + giftMessageId);
SignalDatabase.messages().markGiftRedemptionCompleted(giftMessageId);
MessageTable.MarkedMessageInfo markedMessageInfo = SignalDatabase.messages().setIncomingMessageViewed(giftMessageId);
if (markedMessageInfo != null) {
Log.d(TAG, "Marked gift message viewed for " + giftMessageId);
MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(markedMessageInfo.getSyncMessageId()));
}
}
if (isForOneTimeDonation()) {
SignalStore.inAppPayments().setPendingOneTimeDonation(null);
}
}
private @Nullable ReceiptCredentialPresentation getPresentation() throws InvalidInputException, NoSuchMessageException {
final ReceiptCredentialPresentation receiptCredentialPresentation;
if (isForSubscription()) {
receiptCredentialPresentation = SignalStore.inAppPayments().getSubscriptionReceiptCredential();
} else {
receiptCredentialPresentation = null;
}
if (receiptCredentialPresentation != null) {
return receiptCredentialPresentation;
} if (giftMessageId == NO_ID) {
return getPresentationFromInputData();
} else {
return getPresentationFromGiftMessage();
}
}
private @Nullable ReceiptCredentialPresentation getPresentationFromInputData() throws InvalidInputException {
JsonJobData inputData = JsonJobData.deserialize(getInputData());
if (inputData.isEmpty()) {
Log.w(TAG, "No input data. Exiting.", true);
return null;
}
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
if (presentationBytes == null) {
Log.d(TAG, "No response data. Exiting.", true);
return null;
}
return new ReceiptCredentialPresentation(presentationBytes);
}
private @Nullable ReceiptCredentialPresentation getPresentationFromGiftMessage() throws InvalidInputException, NoSuchMessageException {
MessageRecord messageRecord = SignalDatabase.messages().getMessageRecord(giftMessageId);
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
GiftBadge giftBadge = MessageRecordUtil.requireGiftBadge(messageRecord);
if (giftBadge.redemptionState == GiftBadge.RedemptionState.REDEEMED) {
Log.d(TAG, "Already redeemed this gift badge. Exiting.", true);
return null;
} else {
Log.d(TAG, "Attempting redemption of badge in state " + giftBadge.redemptionState.name());
return new ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray());
}
} else {
Log.d(TAG, "No gift badge on message record. Exiting.", true);
return null;
}
}
private boolean isForSubscription() {
return Objects.requireNonNull(getParameters().getQueue()).startsWith(SUBSCRIPTION_QUEUE);
}
private boolean isForOneTimeDonation() {
return Objects.requireNonNull(getParameters().getQueue()).startsWith(ONE_TIME_QUEUE) && giftMessageId == NO_ID;
}
private void enqueueDonationComplete() {
if (errorSource == DonationErrorSource.GIFT || errorSource == DonationErrorSource.GIFT_REDEMPTION) {
Log.i(TAG, "Skipping donation complete sheet for GIFT related redemption.");
return;
}
if (errorSource == DonationErrorSource.KEEP_ALIVE) {
Log.i(TAG, "Skipping donation complete sheet for subscription KEEP_ALIVE jobchain.");
return;
}
SignalStore.inAppPayments().appendToTerminalDonationQueue(terminalDonation);
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
private final static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<DonationReceiptRedemptionJob> {
@Override
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, parameters);
}
}
}

View File

@@ -1,277 +0,0 @@
/*
* 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.InAppPaymentType
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.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.Companion.toDonationErrorValue
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
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
/**
* Proceeds with an externally approved (say, in a bank app) donation
* and continues to process it.
*/
@Deprecated("Replaced with InAppPaymentAuthCheckJob")
class ExternalLaunchDonationJob private constructor(
private val stripe3DSData: Stripe3DSData,
parameters: Parameters
) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private var donationError: DonationError? = null
companion object {
const val KEY = "ExternalLaunchDonationJob"
private val TAG = Log.tag(ExternalLaunchDonationJob::class.java)
private fun createDonationError(stripe3DSData: Stripe3DSData, throwable: Throwable): DonationError {
val source = stripe3DSData.inAppPayment.type.toErrorSource()
return DonationError.PaymentSetupError.GenericError(source, throwable)
}
}
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, AppDependencies.okHttpClient)
override fun serialize(): ByteArray {
return stripe3DSData.toProtoBytes()
}
override fun getFactoryKey(): String = KEY
override fun onFailure() {
if (donationError != null) {
when (stripe3DSData.inAppPayment.type) {
InAppPaymentType.ONE_TIME_DONATION -> {
SignalStore.inAppPayments.setPendingOneTimeDonation(
DonationSerializationHelper.createPendingOneTimeDonationProto(
Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
stripe3DSData.paymentSourceType,
stripe3DSData.inAppPayment.data.amount!!.toFiatMoney()
).copy(
error = donationError?.toDonationErrorValue()
)
)
}
InAppPaymentType.RECURRING_DONATION -> {
SignalStore.inAppPayments.appendToTerminalDonationQueue(
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.inAppPayment.data.level,
isLongRunningPaymentMethod = stripe3DSData.isLongRunning,
error = donationError?.toDonationErrorValue()
)
)
}
else -> Log.w(TAG, "Job failed with donation error for type: ${stripe3DSData.inAppPayment.type}")
}
}
}
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 inAppPaymentReceiptRecord = if (stripe3DSData.inAppPayment.type == InAppPaymentType.ONE_TIME_DONATION) {
InAppPaymentReceiptRecord.createForBoost(stripe3DSData.inAppPayment.data.amount!!.toFiatMoney())
} else {
InAppPaymentReceiptRecord.createForGift(stripe3DSData.inAppPayment.data.amount!!.toFiatMoney())
}
SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord)
Log.i(TAG, "Creating and inserting one-time pending donation.", true)
SignalStore.inAppPayments.setPendingOneTimeDonation(
DonationSerializationHelper.createPendingOneTimeDonationProto(
Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
stripe3DSData.paymentSourceType,
stripe3DSData.inAppPayment.data.amount.toFiatMoney()
)
)
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 = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
Log.i(TAG, "Setting default payment method...", true)
val setPaymentMethodResponse = if (stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
AppDependencies.donationsService
.setDefaultIdealPaymentMethod(subscriber.subscriberId, stripeSetupIntent.id)
} else {
AppDependencies.donationsService
.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.inAppPayments.setSubscriptionPaymentSourceType(stripe3DSData.paymentSourceType)
val subscriptionLevel = stripe3DSData.inAppPayment.data.level.toString()
try {
val levelUpdateOperation = RecurringInAppPaymentRepository.getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
val updateSubscriptionLevelResponse = AppDependencies.donationsService.updateSubscriptionLevel(
subscriber.subscriberId,
subscriptionLevel,
subscriber.currency.currencyCode,
levelUpdateOperation.idempotencyKey.serialize(),
subscriber.type.lock
)
getResultOrThrow(updateSubscriptionLevelResponse, doOnApplicationError = {
SignalStore.inAppPayments.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.inAppPayments.updateLocalStateForLocalSubscribe(subscriber.type)
SignalStore.inAppPayments.setVerifiedSubscription3DSData(stripe3DSData)
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)
donationError = createDonationError(stripe3DSData, Exception("User cancelled payment."))
throw donationError!!
}
StripeIntentStatus.REQUIRES_PAYMENT_METHOD -> {
Log.i(TAG, "Stripe Intent payment failed, we cannot proceed.", true)
donationError = createDonationError(stripe3DSData, Exception("payment failed"))
throw donationError!!
}
else -> {
Log.i(TAG, "Stripe Intent is still processing, retry later. $stripeIntentStatus", 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()
SignalStore.inAppPayments.appendToTerminalDonationQueue(
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.inAppPayment.data.level,
isLongRunningPaymentMethod = stripe3DSData.isLongRunning,
error = DonationErrorValue(
DonationErrorValue.Type.PAYMENT,
code = serviceResponse.status.toString()
)
)
)
throw serviceResponse.applicationError.get()
} else if (serviceResponse.executionError.isPresent) {
Log.w(TAG, "An execution error was present. ${serviceResponse.status}", serviceResponse.executionError.get(), true)
throw RetryException(serviceResponse.executionError.get())
}
error("Should never get here.")
}
override fun onShouldRetry(e: Exception): Boolean {
return e is RetryException
}
class RetryException(cause: Throwable? = null) : Exception(cause)
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 = parseSerializedData(serializedData)
return ExternalLaunchDonationJob(stripe3DSData, parameters)
}
companion object {
fun parseSerializedData(serializedData: ByteArray): Stripe3DSData {
return Stripe3DSData.fromProtoBytes(serializedData)
}
}
}
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(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
error("Not needed, this job should not be creating intents.")
}
}

View File

@@ -1,116 +0,0 @@
package org.thoughtcrime.securesms.jobs
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.gifts.Gifts
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JsonJobData
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sms.MessageSender
import java.util.concurrent.TimeUnit
/**
* Sends a message to the given recipient containing a redeemable badge token.
* This job assumes that the client has already determined whether the given recipient can receive a gift badge.
*/
@Deprecated("Replaced with InAppPaymentGiftSendJob")
class GiftSendJob private constructor(parameters: Parameters, private val recipientId: RecipientId, private val additionalMessage: String?) : Job(parameters) {
companion object {
private val TAG = Log.tag(GiftSendJob::class.java)
const val KEY = "SendGiftJob"
const val DATA_RECIPIENT_ID = "data.recipient.id"
const val DATA_ADDITIONAL_MESSAGE = "data.additional.message"
}
constructor(recipientId: RecipientId, additionalMessage: String?) :
this(
parameters = Parameters.Builder()
.build(),
recipientId = recipientId,
additionalMessage = additionalMessage
)
override fun serialize(): ByteArray? = JsonJobData.Builder()
.putLong(DATA_RECIPIENT_ID, recipientId.toLong())
.putString(DATA_ADDITIONAL_MESSAGE, additionalMessage)
.serialize()
override fun getFactoryKey(): String = KEY
override fun run(): Result {
Log.i(TAG, "Getting data and generating message for gift send to $recipientId")
val token = JsonJobData.deserialize(this.inputData).getStringAsBlob(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION) ?: return Result.failure()
val recipient = Recipient.resolved(recipientId)
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid recipient $recipientId for gift send.")
return Result.failure()
}
val thread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val outgoingMessage = Gifts.createOutgoingGiftMessage(
recipient = recipient,
expiresIn = TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
sentTimestamp = System.currentTimeMillis(),
giftBadge = GiftBadge(redemptionToken = token.toByteString())
)
Log.i(TAG, "Sending gift badge to $recipientId...")
var didInsert = false
MessageSender.send(context, outgoingMessage, thread, MessageSender.SendType.SIGNAL, null) {
didInsert = true
}
return if (didInsert) {
Log.i(TAG, "Successfully inserted outbox message for gift", true)
val trimmedMessage = additionalMessage?.trim()
if (!trimmedMessage.isNullOrBlank()) {
Log.i(TAG, "Sending additional message...")
val result = MultiShareSender.sendSync(
MultiShareArgs.Builder(setOf(ContactSearchKey.RecipientSearchKey(recipientId, false)))
.withDraftText(trimmedMessage)
.build()
)
if (result.containsFailures()) {
Log.w(TAG, "Failed to send additional message, but gift sent fine.", true)
}
Result.success()
} else {
Result.success()
}
} else {
Log.w(TAG, "Failed to insert outbox message for gift", true)
Result.failure()
}
}
override fun onFailure() {
Log.w(TAG, "Failed to submit send of gift badge to $recipientId")
}
class Factory : Job.Factory<GiftSendJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): GiftSendJob {
val data = JsonJobData.deserialize(serializedData)
val recipientId = RecipientId.from(data.getLong(DATA_RECIPIENT_ID))
val additionalMessage = data.getStringOrDefault(DATA_ADDITIONAL_MESSAGE, null)
return GiftSendJob(parameters, recipientId, additionalMessage)
}
}
}

View File

@@ -11,8 +11,6 @@ import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -125,15 +123,6 @@ class InAppPaymentKeepAliveJob private constructor(
return
}
// Note that this can be removed once the old jobs are decommissioned. These jobs live in different queues, and should still be respected.
if (type == InAppPaymentSubscriberRecord.Type.DONATION) {
val legacyRedemptionStatus = DonationRedemptionJobWatcher.getSubscriptionRedemptionJobStatus()
if (legacyRedemptionStatus != DonationRedemptionJobStatus.None && legacyRedemptionStatus != DonationRedemptionJobStatus.FailedSubscription) {
info(type, "Already trying to redeem donation, current status: ${legacyRedemptionStatus.javaClass.simpleName}")
return
}
}
val activeInAppPayment = getActiveInAppPayment(subscriber, subscription)
if (activeInAppPayment == null) {
warn(type, "Failed to generate active in-app payment. Exiting")

View File

@@ -127,7 +127,6 @@ public final class JobManagerFactories {
put(BackupMessagesJob.KEY, new BackupMessagesJob.Factory());
put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory());
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory());
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
@@ -144,7 +143,6 @@ public final class JobManagerFactories {
put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory());
put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory());
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
@@ -153,7 +151,6 @@ public final class JobManagerFactories {
put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory());
put(ForceUpdateGroupV2WorkerJob.KEY, new ForceUpdateGroupV2WorkerJob.Factory());
put(GenerateAudioWaveFormJob.KEY, new GenerateAudioWaveFormJob.Factory());
put(GiftSendJob.KEY, new GiftSendJob.Factory());
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory());
@@ -203,7 +200,6 @@ 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());
@@ -251,8 +247,6 @@ public final class JobManagerFactories {
put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory());
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
@@ -363,6 +357,12 @@ public final class JobManagerFactories {
put("SmsSentJob", new FailingJob.Factory());
put("MmsSendJobV2", new FailingJob.Factory());
put("AttachmentUploadJobV2", new FailingJob.Factory());
put("SubscriptionKeepAliveJob", new FailingJob.Factory());
put("ExternalLaunchDonationJob", new FailingJob.Factory());
put("BoostReceiptCredentialsSubmissionJob", new FailingJob.Factory());
put("SubscriptionReceiptCredentialsSubmissionJob", new FailingJob.Factory());
put("DonationReceiptRedemptionJob", new FailingJob.Factory());
put("SendGiftJob", new FailingJob.Factory());
}};
}

View File

@@ -1,180 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository;
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus;
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher;
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.locks.Lock;
import okio.ByteString;
/**
* Job that, once there is a valid local subscriber id, should be run every 3 days
* to ensure that a user's subscription does not lapse.
*
* @deprecated Replaced with InAppPaymentKeepAliveJob
*/
@Deprecated()
public class SubscriptionKeepAliveJob extends BaseJob {
public static final String KEY = "SubscriptionKeepAliveJob";
private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class);
private SubscriptionKeepAliveJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
public @Nullable byte[] serialize() {
return null;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
@Override
protected void onRun() throws Exception {
Lock lock = InAppPaymentSubscriberRecord.Type.DONATION.getLock();
lock.lock();
try {
doRun();
} finally {
lock.unlock();
}
}
private void doRun() throws Exception {
InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
if (subscriber == null) {
return;
}
ServiceResponse<EmptyResponse> response = AppDependencies.getDonationsService()
.putSubscription(subscriber.getSubscriberId());
verifyResponse(response);
Log.i(TAG, "Successful call to PUT subscription ID", true);
ServiceResponse<ActiveSubscription> activeSubscriptionResponse = AppDependencies.getDonationsService()
.getSubscription(subscriber.getSubscriberId());
verifyResponse(activeSubscriptionResponse);
Log.i(TAG, "Successful call to GET active subscription", true);
ActiveSubscription activeSubscription = activeSubscriptionResponse.getResult().get();
if (activeSubscription.getActiveSubscription() == null) {
Log.i(TAG, "User does not have a subscription. Exiting.", true);
return;
}
DonationRedemptionJobStatus status = DonationRedemptionJobWatcher.getSubscriptionRedemptionJobStatus();
if (status != DonationRedemptionJobStatus.None.INSTANCE && status != DonationRedemptionJobStatus.FailedSubscription.INSTANCE) {
Log.i(TAG, "Already trying to redeem donation, current status: " + status.getClass().getSimpleName(), true);
return;
}
final long endOfCurrentPeriod = activeSubscription.getActiveSubscription().getEndOfCurrentPeriod();
if (endOfCurrentPeriod > SignalStore.inAppPayments().getLastEndOfPeriod()) {
Log.i(TAG,
String.format(Locale.US,
"Last end of period change. Requesting receipt refresh. (old: %d to new: %d)",
SignalStore.inAppPayments().getLastEndOfPeriod(),
activeSubscription.getActiveSubscription().getEndOfCurrentPeriod()),
true);
SignalStore.inAppPayments().setLastEndOfPeriod(endOfCurrentPeriod);
SignalStore.inAppPayments().clearSubscriptionRequestCredential();
SignalStore.inAppPayments().clearSubscriptionReceiptCredential();
MultiDeviceSubscriptionSyncRequestJob.enqueue();
}
TerminalDonationQueue.TerminalDonation terminalDonation = new TerminalDonationQueue.TerminalDonation(
activeSubscription.getActiveSubscription().getLevel(),
Objects.equals(activeSubscription.getActiveSubscription().getPaymentMethod(), ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT),
null,
ByteString.EMPTY
);
if (endOfCurrentPeriod > SignalStore.inAppPayments().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.inAppPayments().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
SignalStore.inAppPayments().refreshSubscriptionRequestCredential();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, terminalDonation).enqueue();
} else if (endOfCurrentPeriod > SignalStore.inAppPayments().getSubscriptionEndOfPeriodRedemptionStarted()) {
if (SignalStore.inAppPayments().getSubscriptionRequestCredential() == null) {
Log.i(TAG, "We have not started a redemption, but do not have a request credential. Possible that the subscription changed.", true);
return;
}
Log.i(TAG, "We have a request credential and have not yet turned it into a redeemable token.", true);
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, terminalDonation).enqueue();
} else if (endOfCurrentPeriod > SignalStore.inAppPayments().getSubscriptionEndOfPeriodRedeemed()) {
if (SignalStore.inAppPayments().getSubscriptionReceiptCredential() == null) {
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);
return;
}
Log.i(TAG, "We have a receipt credential and have not yet redeemed it.", true);
DonationReceiptRedemptionJob.createJobChainForKeepAlive().enqueue();
} else {
Log.i(TAG, "Subscription is active, and end of current period (remote) is after the latest checked end of period (local). Nothing to do.");
}
}
private <T> void verifyResponse(@NonNull ServiceResponse<T> response) throws Exception {
if (response.getExecutionError().isPresent()) {
Log.w(TAG, "Failed with an execution error. Scheduling retry.", response.getExecutionError().get(), true);
throw new RetryableException();
} else if (response.getApplicationError().isPresent()) {
switch (response.getStatus()) {
case 403:
case 404:
Log.w(TAG, "Invalid or malformed subscriber id. Status: " + response.getStatus(), response.getApplicationError().get(), true);
throw new IOException();
default:
Log.w(TAG, "An unknown server error occurred: " + response.getStatus(), response.getApplicationError().get(), true);
throw new RetryableException();
}
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
private static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<SubscriptionKeepAliveJob> {
@Override
public @NonNull SubscriptionKeepAliveJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
return new SubscriptionKeepAliveJob(parameters);
}
}
}

View File

@@ -1,557 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.Base64;
import org.signal.core.util.logging.Log;
import org.signal.donations.PaymentSourceType;
import org.signal.donations.StripeDeclineCode;
import org.signal.donations.StripeFailureCode;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationsConfigurationExtensionsKt;
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord;
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import okio.ByteString;
/**
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
* we get a response.
*
* * @deprecated Replaced with InAppPaymentRecurringContextJob
*/
@Deprecated()
public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String TAG = Log.tag(SubscriptionReceiptRequestResponseJob.class);
public static final String KEY = "SubscriptionReceiptCredentialsSubmissionJob";
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
private static final String DATA_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_TERMINAL_DONATION = "data.terminal.donation";
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private TerminalDonationQueue.TerminalDonation terminalDonation;
private static SubscriptionReceiptRequestResponseJob createJob(@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setLifespan(terminalDonation.isLongRunningPaymentMethod ? TimeUnit.DAYS.toMillis(30) : TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
subscriberId,
isForKeepAlive,
uiSessionKey,
terminalDonation
);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, @NonNull TerminalDonationQueue.TerminalDonation terminalDonation) {
// TODO [alex] db on main?
InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, terminalDonation);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey, terminalDonation.isLongRunningPaymentMethod);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
return AppDependencies.getJobManager()
.startChain(requestReceiptJob)
.then(redeemReceiptJob)
.then(refreshOwnProfileJob)
.then(multiDeviceProfileContentUpdateJob);
}
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
super(parameters);
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.terminalDonation = terminalDonation;
}
@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)
.putBlobAsString(DATA_TERMINAL_DONATION, terminalDonation.encode());
return builder.serialize();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
if (terminalDonation.error != null) {
SignalStore.inAppPayments().appendToTerminalDonationQueue(terminalDonation);
} else {
Log.w(TAG, "Job is in terminal state without an error on TerminalDonation.");
}
}
@Override
protected void onRun() throws Exception {
Lock lock = InAppPaymentSubscriberRecord.Type.DONATION.getLock();
lock.lock();
try {
doRun();
} finally {
lock.unlock();
}
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (terminalDonation.isLongRunningPaymentMethod) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
}
}
private void doRun() throws Exception {
ReceiptCredentialRequestContext requestContext = SignalStore.inAppPayments().getSubscriptionRequestCredential();
ActiveSubscription activeSubscription = getLatestSubscriptionInformation();
ActiveSubscription.Subscription subscription = activeSubscription.getActiveSubscription();
if (requestContext == null) {
Log.w(TAG, "Request context is null.", true);
throw new Exception("Cannot get a response without a request.");
}
if (subscription == null) {
Log.w(TAG, "Subscription is null.", true);
throw new RetryableException();
} else if (subscription.isFailedPayment()) {
ActiveSubscription.ChargeFailure chargeFailure = activeSubscription.getChargeFailure();
if (chargeFailure != null) {
Log.w(TAG, "Subscription payment charge failure code: " + chargeFailure.getCode() + ", message: " + chargeFailure.getMessage(), true);
}
if (isForKeepAlive) {
Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + "). Payment could still be retried by processor.", true);
throw new Exception("Payment renewal is in retry state, let keep-alive job restart process");
} else {
Log.w(TAG, "New subscription has hit a payment failure. (status = " + subscription.getStatus() + ").", true);
onPaymentFailure(subscription, chargeFailure, false);
throw new Exception("New subscription has hit a payment failure: " + subscription.getStatus());
}
} else if (!subscription.isActive()) {
ActiveSubscription.ChargeFailure chargeFailure = activeSubscription.getChargeFailure();
if (chargeFailure != null) {
Log.w(TAG, "Subscription payment charge failure code: " + chargeFailure.getCode() + ", message: " + chargeFailure.getMessage(), true);
if (!isForKeepAlive) {
Log.w(TAG, "Initial subscription payment failed, treating as a permanent failure.");
onPaymentFailure(subscription, chargeFailure, false);
throw new Exception("New subscription has hit a payment failure.");
}
}
if (isForKeepAlive && subscription.isCanceled()) {
Log.w(TAG, "Permanent payment failure in renewing subscription. (status = " + subscription.getStatus() + ").", true);
onPaymentFailure(subscription, chargeFailure, true);
throw new Exception();
}
Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true);
throw new RetryableException();
} else {
Log.i(TAG, "Subscription is valid, proceeding with request for ReceiptCredentialResponse", true);
long storedEndOfPeriod = SignalStore.inAppPayments().getLastEndOfPeriod();
if (storedEndOfPeriod < subscription.getEndOfCurrentPeriod()) {
Log.i(TAG, "Storing lastEndOfPeriod and syncing with linked devices", true);
SignalStore.inAppPayments().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
MultiDeviceSubscriptionSyncRequestJob.enqueue();
}
if (SignalStore.inAppPayments().getSubscriptionEndOfPeriodConversionStarted() == 0L) {
Log.i(TAG, "Marking the start of initial conversion.", true);
SignalStore.inAppPayments().setSubscriptionEndOfPeriodConversionStarted(subscription.getEndOfCurrentPeriod());
}
}
Log.d(TAG, "Submitting receipt credential request.");
ServiceResponse<ReceiptCredentialResponse> response = AppDependencies.getDonationsService()
.submitReceiptCredentialRequestSync(subscriberId, requestContext.getRequest());
if (response.getApplicationError().isPresent()) {
handleApplicationError(response);
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get());
if (!isCredentialValid(subscription, receiptCredential)) {
onGenericRedemptionError();
throw new IOException("Could not validate receipt credential");
}
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
Log.d(TAG, "Validated credential. Recording receipt and handing off to redemption job.", true);
SignalDatabase.donationReceipts().addReceipt(InAppPaymentReceiptRecord.createForSubscription(subscription));
SignalStore.inAppPayments().clearSubscriptionRequestCredential();
SignalStore.inAppPayments().setSubscriptionReceiptCredential(receiptCredentialPresentation);
SignalStore.inAppPayments().setSubscriptionEndOfPeriodRedemptionStarted(subscription.getEndOfCurrentPeriod());
setOutputData(new JsonJobData.Builder()
.putBlobAsString(DonationReceiptRedemptionJob.INPUT_TERMINAL_DONATION, terminalDonation.encode())
.build()
.serialize());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true);
throw new RetryableException();
}
}
private @NonNull ActiveSubscription getLatestSubscriptionInformation() throws Exception {
ServiceResponse<ActiveSubscription> activeSubscription = AppDependencies.getDonationsService()
.getSubscription(subscriberId);
if (activeSubscription.getResult().isPresent()) {
return activeSubscription.getResult().get();
} else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true);
onGenericRedemptionError();
throw new IOException(activeSubscription.getApplicationError().get());
} else {
throw new RetryableException();
}
}
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
ClientZkReceiptOperations operations = AppDependencies.getClientZkReceiptOperations();
try {
return operations.createReceiptCredentialPresentation(receiptCredential);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e, true);
throw new RetryableException();
}
}
private ReceiptCredential getReceiptCredential(@NonNull ReceiptCredentialRequestContext requestContext, @NonNull ReceiptCredentialResponse response) throws RetryableException {
ClientZkReceiptOperations operations = AppDependencies.getClientZkReceiptOperations();
try {
return operations.receiveReceiptCredential(requestContext, response);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e, true);
throw new RetryableException();
}
}
private void handleApplicationError(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
switch (response.getStatus()) {
case 204:
Log.w(TAG, "Payment is still processing. Trying again.", response.getApplicationError().get(), true);
SignalStore.inAppPayments().clearSubscriptionRedemptionFailed();
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 402:
Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true);
throw new RetryableException();
case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 404:
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 409:
onAlreadyRedeemed(response);
break;
default:
Log.w(TAG, "Encountered a server failure response: " + response.getStatus(), response.getApplicationError().get(), true);
throw new RetryableException();
}
}
private void onGenericRedemptionError() {
terminalDonation = terminalDonation.newBuilder()
.error(new DonationErrorValue(
DonationErrorValue.Type.REDEMPTION,
"",
ByteString.EMPTY
))
.build();
DonationError.routeBackgroundError(
context,
DonationError.genericBadgeRedemptionFailure(getErrorSource())
);
}
private void onPaymentFailedError(DonationError.PaymentSetupError paymentFailure) {
terminalDonation = terminalDonation.newBuilder()
.error(DonationError.toDonationErrorValue(paymentFailure))
.build();
DonationError.routeBackgroundError(
context,
paymentFailure,
terminalDonation.isLongRunningPaymentMethod
);
}
/**
* Handles state updates and error routing for a payment failure.
* <p>
* There are two ways this could go, depending on whether the job was created for a keep-alive chain.
* <p>
* 1. In the case of a normal chain (new subscription) We simply route the error out to the user. The payment failure would have occurred while trying to
* charge for the first month of their subscription, and are likely still on the "Subscribe" screen, so we can just display a dialog.
* 1. In the case of a keep-alive event, we want to book-keep the error to show the user on a subsequent launch, and we want to sync our failure state to
* linked devices.
*/
private void onPaymentFailure(@NonNull ActiveSubscription.Subscription subscription, @Nullable ActiveSubscription.ChargeFailure chargeFailure, boolean isForKeepAlive) {
InAppPaymentSubscriberRecord subscriberRecord = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberRecord, true);
if (isForKeepAlive) {
Log.d(TAG, "Subscription canceled during keep-alive. Setting UnexpectedSubscriptionCancelation state...", true);
SignalStore.inAppPayments().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure);
SignalStore.inAppPayments().setUnexpectedSubscriptionCancelationReason(subscription.getStatus());
SignalStore.inAppPayments().setUnexpectedSubscriptionCancelationTimestamp(subscription.getEndOfCurrentPeriod());
SignalStore.inAppPayments().setShowMonthlyDonationCanceledDialog(true);
AppDependencies.getDonationsService().getDonationsConfiguration(Locale.getDefault()).getResult().ifPresent(config -> {
SignalStore.inAppPayments().setExpiredBadge(DonationsConfigurationExtensionsKt.getBadge(config, subscription.getLevel()));
});
MultiDeviceSubscriptionSyncRequestJob.enqueue();
} else if (chargeFailure != null && subscription.getProcessor() == ActiveSubscription.Processor.STRIPE) {
Log.d(TAG, "Stripe charge failure detected: " + chargeFailure, true);
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
StripeFailureCode failureCode = StripeFailureCode.Companion.getFromCode(chargeFailure.getCode());
DonationError.PaymentSetupError paymentSetupError;
PaymentSourceType paymentSourceType = SignalStore.inAppPayments().getSubscriptionPaymentSourceType();
boolean isStripeSource = paymentSourceType instanceof PaymentSourceType.Stripe;
if (declineCode.isKnown() && isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeDeclinedError(
getErrorSource(),
new Exception(chargeFailure.getMessage()),
declineCode,
(PaymentSourceType.Stripe) paymentSourceType
);
} else if (failureCode.isKnown() && isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeFailureCodeError(
getErrorSource(),
new Exception(chargeFailure.getMessage()),
failureCode,
(PaymentSourceType.Stripe) paymentSourceType
);
} else if (isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeCodedError(
getErrorSource(),
new Exception("Card was declined. " + chargeFailure.getCode()),
chargeFailure.getCode()
);
} else {
paymentSetupError = new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Payment Failed for " + paymentSourceType.getCode())
);
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
onPaymentFailedError(paymentSetupError);
} else if (chargeFailure != null && subscription.getProcessor() == ActiveSubscription.Processor.BRAINTREE) {
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true);
int code;
try {
code = Integer.parseInt(chargeFailure.getCode());
} catch (NumberFormatException e) {
Log.w(TAG, "PayPal charge failure code had unexpected type.");
code = -1;
}
PayPalDeclineCode declineCode = new PayPalDeclineCode(code);
DonationError.PaymentSetupError paymentSetupError;
PaymentSourceType paymentSourceType = SignalStore.inAppPayments().getSubscriptionPaymentSourceType();
boolean isPayPalSource = paymentSourceType instanceof PaymentSourceType.PayPal;
if (declineCode.getKnownCode() != null && isPayPalSource) {
paymentSetupError = new DonationError.PaymentSetupError.PayPalDeclinedError(
getErrorSource(),
new Exception(chargeFailure.getMessage()),
declineCode.getKnownCode()
);
} else if (isPayPalSource) {
paymentSetupError = new DonationError.PaymentSetupError.PayPalCodedError(
getErrorSource(),
new Exception("Card was declined. " + chargeFailure.getCode()),
code
);
} else {
paymentSetupError = new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Payment Failed for " + paymentSourceType.getCode())
);
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
onPaymentFailedError(paymentSetupError);
} else {
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
onPaymentFailedError(new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Got a failure status from the subscription object.")
));
}
}
/**
* Handle 409 error code. This is a permanent failure for new subscriptions but an ignorable error for keep-alive messages.
*/
private void onAlreadyRedeemed(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
if (isForKeepAlive) {
Log.i(TAG, "KeepAlive: Latest paid receipt on subscription already redeemed with a different request credential, ignoring.", response.getApplicationError().get(), true);
setOutputData(new JsonJobData.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).serialize());
} else {
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
}
}
private DonationErrorSource getErrorSource() {
return isForKeepAlive ? DonationErrorSource.KEEP_ALIVE : DonationErrorSource.MONTHLY;
}
/**
* Checks that the generated Receipt Credential has the following characteristics
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
* - expiration time should have the following characteristics:
* - expiration_time mod 86400 == 0
* - expiration_time is between now and 90 days from now
*/
private static boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
boolean isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
boolean isExpirationWithinMax = receiptCredential.getReceiptExpirationTime() <= maxExpirationTime;
Log.d(TAG, "Credential validation: isSameLevel(" + isSameLevel +
") isExpirationAfterSub(" + isExpirationAfterSub +
") isExpiration86400(" + isExpiration86400 +
") isExpirationInTheFuture(" + isExpirationInTheFuture +
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax;
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
@VisibleForTesting final static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<SubscriptionReceiptRequestResponseJob> {
@Override
public @NonNull SubscriptionReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
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);
byte[] rawTerminalDonation = data.getStringAsBlob(DATA_TERMINAL_DONATION);
ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.inAppPayments().getSubscriptionRequestCredential() == null) {
try {
requestContext = new ReceiptCredentialRequestContext(requestContextBytes);
SignalStore.inAppPayments().setSubscriptionRequestCredential(requestContext);
} catch (InvalidInputException e) {
Log.e(TAG, "Failed to generate request context from bytes", e);
throw new AssertionError(e);
}
}
TerminalDonationQueue.TerminalDonation terminalDonation = null;
if (rawTerminalDonation != null) {
try {
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} catch (IOException e) {
Log.e(TAG, "Failed to parse terminal donation. Generating a default.");
}
}
if (terminalDonation == null) {
terminalDonation = new TerminalDonationQueue.TerminalDonation(
-1,
false,
null,
ByteString.EMPTY
);
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, terminalDonation);
}
}
}