kopia lustrzana https://github.com/ryukoposting/Signal-Android
399 wiersze
21 KiB
Java
399 wiersze
21 KiB
Java
package org.thoughtcrime.securesms.jobs;
|
|
|
|
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.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.errors.DonationError;
|
|
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
|
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
|
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord;
|
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
|
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|
import org.thoughtcrime.securesms.subscription.Subscriber;
|
|
import org.thoughtcrime.securesms.util.Base64;
|
|
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.concurrent.TimeUnit;
|
|
|
|
/**
|
|
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
|
|
* we get a response.
|
|
*/
|
|
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";
|
|
|
|
public static final Object MUTEX = new Object();
|
|
|
|
private final SubscriberId subscriberId;
|
|
private final boolean isForKeepAlive;
|
|
|
|
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive) {
|
|
return new SubscriptionReceiptRequestResponseJob(
|
|
new Parameters
|
|
.Builder()
|
|
.addConstraint(NetworkConstraint.KEY)
|
|
.setQueue("ReceiptRedemption")
|
|
.setMaxInstancesForQueue(1)
|
|
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
|
.setMaxAttempts(Parameters.UNLIMITED)
|
|
.build(),
|
|
subscriberId,
|
|
isForKeepAlive
|
|
);
|
|
}
|
|
|
|
public static JobManager.Chain createSubscriptionContinuationJobChain() {
|
|
return createSubscriptionContinuationJobChain(false);
|
|
}
|
|
|
|
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive) {
|
|
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
|
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive);
|
|
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource());
|
|
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
|
|
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
|
|
|
return ApplicationDependencies.getJobManager()
|
|
.startChain(requestReceiptJob)
|
|
.then(redeemReceiptJob)
|
|
.then(refreshOwnProfileJob)
|
|
.then(multiDeviceProfileContentUpdateJob);
|
|
}
|
|
|
|
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
|
|
@NonNull SubscriberId subscriberId,
|
|
boolean isForKeepAlive)
|
|
{
|
|
super(parameters);
|
|
this.subscriberId = subscriberId;
|
|
this.isForKeepAlive = isForKeepAlive;
|
|
}
|
|
|
|
@Override
|
|
public @NonNull Data serialize() {
|
|
Data.Builder builder = new Data.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
|
|
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive);
|
|
|
|
return builder.build();
|
|
}
|
|
|
|
@Override
|
|
public @NonNull String getFactoryKey() {
|
|
return KEY;
|
|
}
|
|
|
|
@Override
|
|
public void onFailure() {
|
|
}
|
|
|
|
@Override
|
|
protected void onRun() throws Exception {
|
|
synchronized (MUTEX) {
|
|
doRun();
|
|
}
|
|
}
|
|
|
|
private void doRun() throws Exception {
|
|
ReceiptCredentialRequestContext requestContext = SignalStore.donationsValues().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() + ").", true);
|
|
onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), true);
|
|
throw new Exception("Active subscription hit a payment failure: " + subscription.getStatus());
|
|
} else {
|
|
Log.w(TAG, "New subscription has hit a payment failure. (status = " + subscription.getStatus() + ").", true);
|
|
onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), 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.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), false);
|
|
throw new Exception("New subscription has hit a payment failure.");
|
|
}
|
|
}
|
|
|
|
Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true);
|
|
throw new RetryableException();
|
|
} else if (subscription.isCanceled()) {
|
|
Log.w(TAG, "Subscription is marked as cancelled, but it's possible that the user cancelled and then later tried to resubscribe. Scheduling a retry.", true);
|
|
throw new RetryableException();
|
|
} else {
|
|
Log.i(TAG, "Subscription is valid, proceeding with request for ReceiptCredentialResponse", true);
|
|
long storedEndOfPeriod = SignalStore.donationsValues().getLastEndOfPeriod();
|
|
if (storedEndOfPeriod < subscription.getEndOfCurrentPeriod()) {
|
|
Log.i(TAG, "Storing lastEndOfPeriod and syncing with linked devices", true);
|
|
SignalStore.donationsValues().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
|
|
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
|
}
|
|
|
|
if (SignalStore.donationsValues().getSubscriptionEndOfPeriodConversionStarted() == 0L) {
|
|
Log.i(TAG, "Marking the start of initial conversion.", true);
|
|
SignalStore.donationsValues().setSubscriptionEndOfPeriodConversionStarted(subscription.getEndOfCurrentPeriod());
|
|
}
|
|
}
|
|
|
|
Log.d(TAG, "Submitting receipt credential request.");
|
|
ServiceResponse<ReceiptCredentialResponse> response = ApplicationDependencies.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)) {
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
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(DonationReceiptRecord.createForSubscription(subscription));
|
|
|
|
SignalStore.donationsValues().clearSubscriptionRequestCredential();
|
|
SignalStore.donationsValues().setSubscriptionReceiptCredential(receiptCredentialPresentation);
|
|
SignalStore.donationsValues().setSubscriptionEndOfPeriodRedemptionStarted(subscription.getEndOfCurrentPeriod());
|
|
} 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 = ApplicationDependencies.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);
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
throw new IOException(activeSubscription.getApplicationError().get());
|
|
} else {
|
|
throw new RetryableException();
|
|
}
|
|
}
|
|
|
|
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
|
|
ClientZkReceiptOperations operations = ApplicationDependencies.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 = ApplicationDependencies.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.donationsValues().clearSubscriptionRedemptionFailed();
|
|
throw new RetryableException();
|
|
case 400:
|
|
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
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);
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
throw new Exception(response.getApplicationError().get());
|
|
case 404:
|
|
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 String status, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp, boolean isForKeepAlive) {
|
|
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
|
|
if (isForKeepAlive) {
|
|
Log.d(TAG, "Is for a keep-alive and we have a status. Setting UnexpectedSubscriptionCancelation state...", true);
|
|
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure);
|
|
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status);
|
|
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp);
|
|
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
|
} else if (chargeFailure != null) {
|
|
Log.d(TAG, "Charge failure detected: " + chargeFailure, true);
|
|
|
|
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
|
|
DonationError.PaymentSetupError paymentSetupError;
|
|
|
|
if (declineCode.isKnown()) {
|
|
paymentSetupError = new DonationError.PaymentSetupError.DeclinedError(
|
|
getErrorSource(),
|
|
new Exception(chargeFailure.getMessage()),
|
|
declineCode,
|
|
SignalStore.donationsValues().getSubscriptionPaymentSourceType()
|
|
);
|
|
} else {
|
|
paymentSetupError = new DonationError.PaymentSetupError.CodedError(
|
|
getErrorSource(),
|
|
new Exception("Card was declined. " + chargeFailure.getCode()),
|
|
chargeFailure.getCode()
|
|
);
|
|
}
|
|
|
|
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
|
|
DonationError.routeDonationError(context, paymentSetupError);
|
|
} else {
|
|
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
|
|
DonationError.routeDonationError(context, 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 Data.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).build());
|
|
} else {
|
|
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
|
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
|
throw new Exception(response.getApplicationError().get());
|
|
}
|
|
}
|
|
|
|
private DonationErrorSource getErrorSource() {
|
|
return isForKeepAlive ? DonationErrorSource.KEEP_ALIVE : DonationErrorSource.SUBSCRIPTION;
|
|
}
|
|
|
|
/**
|
|
* 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, @NonNull Data data) {
|
|
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;
|
|
|
|
ReceiptCredentialRequestContext requestContext;
|
|
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
|
|
try {
|
|
requestContext = new ReceiptCredentialRequestContext(requestContextBytes);
|
|
SignalStore.donationsValues().setSubscriptionRequestCredential(requestContext);
|
|
} catch (InvalidInputException e) {
|
|
Log.e(TAG, "Failed to generate request context from bytes", e);
|
|
throw new AssertionError(e);
|
|
}
|
|
}
|
|
|
|
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive);
|
|
}
|
|
}
|
|
}
|