mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 04:28:35 +00:00
Remove old donation jobs.
This commit is contained in:
committed by
Greyson Parrelli
parent
ed1348c20d
commit
e82dfea93c
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
@@ -323,46 +317,52 @@ public final class JobManagerFactories {
|
||||
put(WallpaperStorageMigrationJob.KEY, new WallpaperStorageMigrationJob.Factory());
|
||||
|
||||
// Dead jobs
|
||||
put(FailingJob.KEY, new FailingJob.Factory());
|
||||
put(PassingMigrationJob.KEY, new PassingMigrationJob.Factory());
|
||||
put("PushContentReceiveJob", new FailingJob.Factory());
|
||||
put("AttachmentUploadJob", new FailingJob.Factory());
|
||||
put("MmsSendJob", new FailingJob.Factory());
|
||||
put("RefreshUnidentifiedDeliveryAbilityJob", new FailingJob.Factory());
|
||||
put("Argon2TestJob", new FailingJob.Factory());
|
||||
put("Argon2TestMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("StorageKeyRotationMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("StorageSyncJob", new StorageSyncJob.Factory());
|
||||
put("WakeGroupV2Job", new FailingJob.Factory());
|
||||
put("LeaveGroupJob", new FailingJob.Factory());
|
||||
put("PushGroupUpdateJob", new FailingJob.Factory());
|
||||
put("RequestGroupInfoJob", new FailingJob.Factory());
|
||||
put("RotateSignedPreKeyJob", new PreKeysSyncJob.Factory());
|
||||
put("CreateSignedPreKeyJob", new PreKeysSyncJob.Factory());
|
||||
put("RefreshPreKeysJob", new PreKeysSyncJob.Factory());
|
||||
put("RecipientChangedNumberJob", new FailingJob.Factory());
|
||||
put("PushTextSendJob", new IndividualSendJob.Factory());
|
||||
put("MultiDevicePniIdentityUpdateJob", new FailingJob.Factory());
|
||||
put("MultiDeviceGroupUpdateJob", new FailingJob.Factory());
|
||||
put("CallSyncEventJob", new FailingJob.Factory());
|
||||
put("RegistrationPinV2MigrationJob", new FailingJob.Factory());
|
||||
put("KbsEnclaveMigrationWorkerJob", new FailingJob.Factory());
|
||||
put("KbsEnclaveMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("ClearFallbackKbsEnclaveJob", new FailingJob.Factory());
|
||||
put("PushDecryptJob", new FailingJob.Factory());
|
||||
put("PushDecryptDrainedJob", new FailingJob.Factory());
|
||||
put("PushProcessJob", new FailingJob.Factory());
|
||||
put("DecryptionsDrainedMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("MmsReceiveJob", new FailingJob.Factory());
|
||||
put("MmsDownloadJob", new FailingJob.Factory());
|
||||
put("SmsReceiveJob", new FailingJob.Factory());
|
||||
put("StoryReadStateMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("GroupV1MigrationJob", new FailingJob.Factory());
|
||||
put("NewRegistrationUsernameSyncJob", new FailingJob.Factory());
|
||||
put("SmsSendJob", new FailingJob.Factory());
|
||||
put("SmsSentJob", new FailingJob.Factory());
|
||||
put("MmsSendJobV2", new FailingJob.Factory());
|
||||
put("AttachmentUploadJobV2", new FailingJob.Factory());
|
||||
put(FailingJob.KEY, new FailingJob.Factory());
|
||||
put(PassingMigrationJob.KEY, new PassingMigrationJob.Factory());
|
||||
put("PushContentReceiveJob", new FailingJob.Factory());
|
||||
put("AttachmentUploadJob", new FailingJob.Factory());
|
||||
put("MmsSendJob", new FailingJob.Factory());
|
||||
put("RefreshUnidentifiedDeliveryAbilityJob", new FailingJob.Factory());
|
||||
put("Argon2TestJob", new FailingJob.Factory());
|
||||
put("Argon2TestMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("StorageKeyRotationMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("StorageSyncJob", new StorageSyncJob.Factory());
|
||||
put("WakeGroupV2Job", new FailingJob.Factory());
|
||||
put("LeaveGroupJob", new FailingJob.Factory());
|
||||
put("PushGroupUpdateJob", new FailingJob.Factory());
|
||||
put("RequestGroupInfoJob", new FailingJob.Factory());
|
||||
put("RotateSignedPreKeyJob", new PreKeysSyncJob.Factory());
|
||||
put("CreateSignedPreKeyJob", new PreKeysSyncJob.Factory());
|
||||
put("RefreshPreKeysJob", new PreKeysSyncJob.Factory());
|
||||
put("RecipientChangedNumberJob", new FailingJob.Factory());
|
||||
put("PushTextSendJob", new IndividualSendJob.Factory());
|
||||
put("MultiDevicePniIdentityUpdateJob", new FailingJob.Factory());
|
||||
put("MultiDeviceGroupUpdateJob", new FailingJob.Factory());
|
||||
put("CallSyncEventJob", new FailingJob.Factory());
|
||||
put("RegistrationPinV2MigrationJob", new FailingJob.Factory());
|
||||
put("KbsEnclaveMigrationWorkerJob", new FailingJob.Factory());
|
||||
put("KbsEnclaveMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("ClearFallbackKbsEnclaveJob", new FailingJob.Factory());
|
||||
put("PushDecryptJob", new FailingJob.Factory());
|
||||
put("PushDecryptDrainedJob", new FailingJob.Factory());
|
||||
put("PushProcessJob", new FailingJob.Factory());
|
||||
put("DecryptionsDrainedMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("MmsReceiveJob", new FailingJob.Factory());
|
||||
put("MmsDownloadJob", new FailingJob.Factory());
|
||||
put("SmsReceiveJob", new FailingJob.Factory());
|
||||
put("StoryReadStateMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("GroupV1MigrationJob", new FailingJob.Factory());
|
||||
put("NewRegistrationUsernameSyncJob", new FailingJob.Factory());
|
||||
put("SmsSendJob", new FailingJob.Factory());
|
||||
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());
|
||||
}};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user