mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 12:38:33 +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.DonationErrorSource
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
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.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.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
|
||||||
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
|
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
|
||||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||||
@@ -522,21 +521,13 @@ object InAppPaymentsRepository {
|
|||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun hasPendingDonation(): Boolean {
|
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.
|
* 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> {
|
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 fromDatabase: Observable<DonationRedemptionJobStatus> = Observable.create { emitter ->
|
||||||
val observer = InAppPaymentObserver {
|
val observer = InAppPaymentObserver {
|
||||||
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
||||||
@@ -547,7 +538,7 @@ object InAppPaymentsRepository {
|
|||||||
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
|
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
|
||||||
emitter.setCancellable { AppDependencies.databaseObserver.unregisterObserver(observer) }
|
emitter.setCancellable { AppDependencies.databaseObserver.unregisterObserver(observer) }
|
||||||
}.switchMap { inAppPaymentOptional ->
|
}.switchMap { inAppPaymentOptional ->
|
||||||
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap jobStatusObservable
|
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap Observable.just(DonationRedemptionJobStatus.None)
|
||||||
|
|
||||||
val value = when (inAppPayment.state) {
|
val value = when (inAppPayment.state) {
|
||||||
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
|
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
|
||||||
@@ -576,15 +567,7 @@ object InAppPaymentsRepository {
|
|||||||
Observable.just(value)
|
Observable.just(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fromDatabase
|
return fromDatabase.distinctUntilChanged()
|
||||||
.switchMap {
|
|
||||||
if (it == DonationRedemptionJobStatus.None) {
|
|
||||||
jobStatusObservable
|
|
||||||
} else {
|
|
||||||
Observable.just(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scheduleSyncForAccountRecordChange() {
|
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.DonationSerializationHelper.toDecimalValue
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
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.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.InAppPaymentTable
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||||
@@ -125,15 +123,6 @@ class InAppPaymentKeepAliveJob private constructor(
|
|||||||
return
|
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)
|
val activeInAppPayment = getActiveInAppPayment(subscriber, subscription)
|
||||||
if (activeInAppPayment == null) {
|
if (activeInAppPayment == null) {
|
||||||
warn(type, "Failed to generate active in-app payment. Exiting")
|
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(BackupMessagesJob.KEY, new BackupMessagesJob.Factory());
|
||||||
put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory());
|
put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory());
|
||||||
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
|
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
|
||||||
put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory());
|
|
||||||
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
|
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
|
||||||
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
|
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
|
||||||
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
|
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
|
||||||
@@ -144,7 +143,6 @@ public final class JobManagerFactories {
|
|||||||
put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory());
|
put(CreateReleaseChannelJob.KEY, new CreateReleaseChannelJob.Factory());
|
||||||
put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory());
|
put(DeleteAbandonedAttachmentsJob.KEY, new DeleteAbandonedAttachmentsJob.Factory());
|
||||||
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
|
put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory());
|
||||||
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
|
|
||||||
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
|
put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory());
|
||||||
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
|
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
|
||||||
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
||||||
@@ -153,7 +151,6 @@ public final class JobManagerFactories {
|
|||||||
put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory());
|
put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory());
|
||||||
put(ForceUpdateGroupV2WorkerJob.KEY, new ForceUpdateGroupV2WorkerJob.Factory());
|
put(ForceUpdateGroupV2WorkerJob.KEY, new ForceUpdateGroupV2WorkerJob.Factory());
|
||||||
put(GenerateAudioWaveFormJob.KEY, new GenerateAudioWaveFormJob.Factory());
|
put(GenerateAudioWaveFormJob.KEY, new GenerateAudioWaveFormJob.Factory());
|
||||||
put(GiftSendJob.KEY, new GiftSendJob.Factory());
|
|
||||||
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
||||||
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
|
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
|
||||||
put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory());
|
put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory());
|
||||||
@@ -203,7 +200,6 @@ public final class JobManagerFactories {
|
|||||||
put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory());
|
put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory());
|
||||||
put(PnpInitializeDevicesJob.KEY, new PnpInitializeDevicesJob.Factory());
|
put(PnpInitializeDevicesJob.KEY, new PnpInitializeDevicesJob.Factory());
|
||||||
put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory());
|
put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory());
|
||||||
put(ExternalLaunchDonationJob.KEY, new ExternalLaunchDonationJob.Factory());
|
|
||||||
put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory());
|
put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory());
|
||||||
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
|
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
|
||||||
put(PushDistributionListSendJob.KEY, new PushDistributionListSendJob.Factory());
|
put(PushDistributionListSendJob.KEY, new PushDistributionListSendJob.Factory());
|
||||||
@@ -251,8 +247,6 @@ public final class JobManagerFactories {
|
|||||||
put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory());
|
put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory());
|
||||||
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
|
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
|
||||||
put(StorageSyncJob.KEY, new StorageSyncJob.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(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
|
||||||
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
|
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
|
||||||
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
|
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
|
||||||
@@ -363,6 +357,12 @@ public final class JobManagerFactories {
|
|||||||
put("SmsSentJob", new FailingJob.Factory());
|
put("SmsSentJob", new FailingJob.Factory());
|
||||||
put("MmsSendJobV2", new FailingJob.Factory());
|
put("MmsSendJobV2", new FailingJob.Factory());
|
||||||
put("AttachmentUploadJobV2", 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