Add better application error handling for badges and token redemption.

fork-5.53.8
Alex Hart 2021-11-05 12:28:07 -03:00 zatwierdzone przez Greyson Parrelli
rodzic 2b6190bf34
commit b8dc541fc5
10 zmienionych plików z 160 dodań i 45 usunięć

Wyświetl plik

@ -36,6 +36,10 @@ data class Badge(
val visible: Boolean,
) : Parcelable, Key {
fun setVisible(): Badge {
return copy(visible = true)
}
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
fun isBoost(): Boolean = id == BOOST_BADGE_ID

Wyświetl plik

@ -2,4 +2,5 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
class DonationExceptions {
object TimedOutWaitingForTokenRedemption : Exception()
object RedemptionFailed : Exception()
}

Wyświetl plik

@ -12,6 +12,7 @@ import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -114,15 +115,30 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
val jobId = BoostReceiptRequestResponseJob.enqueueChain(paymentIntent)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
ApplicationDependencies.getJobManager().addListener(jobId) { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onComplete()
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Request response job chain failed permanently.", true)
it.onError(DonationExceptions.RedemptionFailed)
}
else -> {
Log.d(TAG, "Request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}
} else {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
@ -137,7 +153,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel")
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
subscriber.subscriberId,
@ -145,27 +161,46 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize()
).flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().andThen {
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel", true)
SignalStore.donationsValues().clearUserManuallyCancelled()
SignalStore.donationsValues().clearLevelOperation()
LevelUpdate.updateProcessingState(false)
it.onComplete()
}.andThen {
Log.d(TAG, "Enqueuing request response job chain.", true)
val jobId = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
ApplicationDependencies.getJobManager().addListener(jobId) { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onComplete()
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Request response job chain failed permanently.", true)
it.onError(DonationExceptions.RedemptionFailed)
}
else -> {
Log.d(TAG, "Request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}
} else {
Log.d(TAG, "Request response job timed out.", true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
} catch (e: InterruptedException) {
Log.w(TAG, "Request response interrupted.", e, true)
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}

Wyświetl plik

@ -21,11 +21,13 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.LifecycleDisposable
@ -201,7 +203,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
Log.w(TAG, "Timed out while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
@ -210,8 +212,19 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
findNavController().popBackStack()
}
.show()
} else if (throwable is DonationExceptions.RedemptionFailed) {
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_failed)
.setMessage(R.string.DonationsErrors__please_contact_support)
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
.show()
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
Log.w(TAG, "Error occurred while processing payment", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)

Wyświetl plik

@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationE
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.CommunicationActions
@ -235,7 +236,7 @@ class SubscribeFragment : DSLSettingsFragment(
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
Log.w(TAG, "Timeout occurred while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
@ -245,8 +246,19 @@ class SubscribeFragment : DSLSettingsFragment(
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
.show()
} else if (throwable is DonationExceptions.RedemptionFailed) {
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_failed)
.setMessage(R.string.DonationsErrors__please_contact_support)
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
.show()
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
Log.w(TAG, "Error occurred while processing payment", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)
@ -267,7 +279,7 @@ class SubscribeFragment : DSLSettingsFragment(
}
private fun onSubscriptionFailedToCancel(throwable: Throwable) {
Log.w(TAG, "Failed to cancel subscription", throwable)
Log.w(TAG, "Failed to cancel subscription", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)

Wyświetl plik

@ -19,9 +19,6 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.subscription.SubscriptionNotification;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
@ -122,12 +119,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.blockingGet();
if (response.getApplicationError().isPresent()) {
if (response.getStatus() == 204) {
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get());
} else {
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), response.getApplicationError().get());
throw new RetryableException();
}
handleApplicationError(response);
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
@ -140,18 +132,36 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
receiptCredentialPresentation.serialize())
.build());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull());
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull(), true);
throw new RetryableException();
}
}
private static void handleApplicationError(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) {
case 204:
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", applicationException, true);
break;
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
throw new Exception(applicationException);
case 409:
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
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 = ApplicationDependencies.getClientZkReceiptOperations();
try {
return operations.createReceiptCredentialPresentation(receiptCredential);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e);
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}
@ -163,7 +173,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
try {
return operations.receiveReceiptCredential(requestContext, response);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e);
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}

Wyświetl plik

@ -4,19 +4,14 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
@ -75,13 +70,13 @@ public class DonationReceiptRedemptionJob extends BaseJob {
Data inputData = getInputData();
if (inputData == null) {
Log.w(TAG, "No input data. Failing.");
Log.w(TAG, "No input data. Failing.", null, true);
throw new IllegalStateException("Expected a presentation object in input data.");
}
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
if (presentationBytes == null) {
Log.d(TAG, "No response data. Exiting.");
Log.d(TAG, "No response data. Exiting.", null, true);
return;
}
@ -92,10 +87,15 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.blockingGet();
if (response.getApplicationError().isPresent()) {
Log.w(TAG, "Encountered a non-recoverable exception", response.getApplicationError().get());
throw new IOException(response.getApplicationError().get());
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);
throw new IOException(response.getApplicationError().get());
}
} else if (response.getExecutionError().isPresent()) {
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get());
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get(), true);
throw new RetryableException();
}
}

Wyświetl plik

@ -8,6 +8,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.badges.BadgeRepository;
import org.thoughtcrime.securesms.badges.Badges;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
@ -30,6 +31,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import java.io.IOException;
import java.sql.Ref;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
@ -213,9 +215,27 @@ public class RefreshOwnProfileJob extends BaseJob {
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
}
DatabaseFactory.getRecipientDatabase(context)
.setBadges(Recipient.self().getId(),
badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList()));
boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
List<Badge> appBadges = badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList());
if (userHasVisibleBadges && userHasInvisibleBadges) {
Log.d(TAG, "Detected mixed visibility of badges. Telling the server to mark them all visible.", true);
BadgeRepository badgeRepository = new BadgeRepository(context);
badgeRepository.setVisibilityForAllBadges(true);
DatabaseFactory.getRecipientDatabase(context)
.setBadges(Recipient.self().getId(),
appBadges.stream()
.map(Badge::setVisible)
.collect(Collectors.toList()));
} else {
DatabaseFactory.getRecipientDatabase(context)
.setBadges(Recipient.self().getId(), appBadges);
}
}
private static boolean isSubscription(String badgeId) {

Wyświetl plik

@ -109,10 +109,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
protected void onRun() throws Exception {
ActiveSubscription.Subscription subscription = getLatestSubscriptionInformation();
if (subscription == null || !subscription.isActive()) {
Log.d(TAG, "User does not have an active subscription. Exiting.");
Log.d(TAG, "User does not have an active subscription. Exiting.", true);
return;
} else {
Log.i(TAG, "Recording end of period from active subscription.");
Log.i(TAG, "Recording end of period from active subscription.", true);
SignalStore.donationsValues().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
}
@ -133,12 +133,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
.blockingGet();
if (response.getApplicationError().isPresent()) {
if (response.getStatus() == 204) {
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get());
} else {
Log.w(TAG, "Encountered a server failure response: " + response.getStatus(), response.getApplicationError().get());
throw new RetryableException();
}
handleApplicationError(response);
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
@ -151,7 +146,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
receiptCredentialPresentation.serialize())
.build());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull());
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull(), true);
throw new RetryableException();
}
}
@ -164,7 +159,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
if (activeSubscription.getResult().isPresent()) {
return activeSubscription.getResult().get().getActiveSubscription();
} else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.");
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true);
throw new IOException(activeSubscription.getApplicationError().get());
} else {
throw new RetryableException();
@ -177,7 +172,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
try {
return operations.createReceiptCredentialPresentation(receiptCredential);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e);
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}
@ -189,12 +184,35 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
try {
return operations.receiveReceiptCredential(requestContext, response);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e);
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e, true);
requestContext = null;
throw new RetryableException();
}
}
private static void handleApplicationError(ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
switch (response.getStatus()) {
case 204:
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get(), true);
break;
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());
case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());
case 404:
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());
case 409:
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
throw new Exception(response.getApplicationError().get());
default:
Log.w(TAG, "Encountered a server failure response: " + response.getStatus(), response.getApplicationError().get(), true);
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

Wyświetl plik

@ -4007,6 +4007,8 @@
<string name="DonationsErrors__payment_failed">Payment failed</string>
<string name="DonationsErrors__your_payment">Your payment couldn\'t be processed and you have not been charged. Please try again.</string>
<string name="DonationsErrors__redemption_still_pending">Redemption still pending</string>
<string name="DonationsErrors__redemption_failed">Redemption failed</string>
<string name="DonationsErrors__please_contact_support">Please contact support</string>
<string name="DonationsErrors__you_might_not_see_your_badge_right_away">You may not see your badge right away, but we\'re working on it!</string>
<string name="DonationsErrors__google_pay_unavailable">Google Pay Unavailable</string>
<string name="DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app">You have to set up Google Pay to donate in-app.</string>