Add better handling for unexpected cancellations.

This commit is contained in:
Alex Hart 2022-05-16 16:19:26 -03:00 committed by Cody Henthorne
parent 77f8489e51
commit 588663b3c2
11 changed files with 115 additions and 26 deletions

View file

@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.content.Context; import android.content.Context;
import android.os.Build; import android.os.Build;
import android.os.StrictMode;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -87,7 +86,6 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler; import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@ -99,7 +97,6 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
import java.net.SocketException; import java.net.SocketException;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.security.Security; import java.security.Security;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException; import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
@ -221,7 +218,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getFrameRateTracker().start(); ApplicationDependencies.getFrameRateTracker().start();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded(); ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
ApplicationDependencies.getDeadlockDetector().start(); ApplicationDependencies.getDeadlockDetector().start();
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary(); SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
SignalExecutors.BOUNDED.execute(() -> { SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary(); FeatureFlags.refreshIfNecessary();

View file

@ -40,6 +40,8 @@ data class Badge(
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0 fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
fun isBoost(): Boolean = id == BOOST_BADGE_ID fun isBoost(): Boolean = id == BOOST_BADGE_ID
fun isGift(): Boolean = id == GIFT_BADGE_ID
fun isSubscription(): Boolean = !isBoost() && !isGift()
override fun updateDiskCacheKey(messageDigest: MessageDigest) { override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(id.toByteArray(Key.CHARSET)) messageDigest.update(id.toByteArray(Key.CHARSET))

View file

@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Un
import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/** /**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again. * Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
@ -30,7 +31,8 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
private fun getConfiguration(): DSLConfiguration { private fun getConfiguration(): DSLConfiguration {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()) val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge val badge: Badge = args.badge
val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason) val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer() val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE

View file

@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
import org.thoughtcrime.securesms.jobs.StorageForcePushJob import org.thoughtcrime.securesms.jobs.StorageForcePushJob
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.DataExportUtil import org.thoughtcrime.securesms.payments.DataExportUtil
@ -401,6 +402,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
enqueueSubscriptionRedemption() enqueueSubscriptionRedemption()
} }
) )
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_enqueue_keep_alive),
onClick = {
enqueueSubscriptionKeepAlive()
}
)
} }
dividerPref() dividerPref()
@ -573,6 +581,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue() SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
} }
private fun enqueueSubscriptionKeepAlive() {
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
}
private fun clearCdsHistory() { private fun clearCdsHistory() {
SignalDatabase.cds.clearAll() SignalDatabase.cds.clearAll()
SignalStore.misc().cdsToken = null SignalStore.misc().cdsToken = null

View file

@ -277,9 +277,8 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
).flatMapCompletable { ).flatMapCompletable {
if (it.status == 200 || it.status == 204) { if (it.status == 200 || it.status == 204) {
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true) Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true)
SignalStore.donationsValues().clearUserManuallyCancelled() SignalStore.donationsValues().updateLocalStateForLocalSubscribe()
scheduleSyncForAccountRecordChange() scheduleSyncForAccountRecordChange()
SignalStore.donationsValues().clearLevelOperations()
LevelUpdate.updateProcessingState(false) LevelUpdate.updateProcessingState(false)
Completable.complete() Completable.complete()
} else { } else {

View file

@ -164,12 +164,7 @@ class SubscribeViewModel(
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable { return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
if (it) { if (it) {
donationPaymentRepository.cancelActiveSubscription().doOnComplete { donationPaymentRepository.cancelActiveSubscription().doOnComplete {
SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().updateLocalStateForManualCancellation()
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
MultiDeviceSubscriptionSyncRequestJob.enqueue() MultiDeviceSubscriptionSyncRequestJob.enqueue()
} }
} else { } else {
@ -183,12 +178,7 @@ class SubscribeViewModel(
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy( disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
onComplete = { onComplete = {
eventPublisher.onNext(DonationEvent.SubscriptionCancelled) eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
SignalStore.donationsValues().setLastEndOfPeriod(0L) SignalStore.donationsValues().updateLocalStateForManualCancellation()
SignalStore.donationsValues().clearLevelOperations()
SignalStore.donationsValues().markUserManuallyCancelled()
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
refreshActiveSubscription() refreshActiveSubscription()
MultiDeviceSubscriptionSyncRequestJob.enqueue() MultiDeviceSubscriptionSyncRequestJob.enqueue()
donationPaymentRepository.scheduleSyncForAccountRecordChange() donationPaymentRepository.scheduleSyncForAccountRecordChange()

View file

@ -299,6 +299,10 @@ public class RefreshOwnProfileJob extends BaseJob {
Log.d(TAG, "Unexpected expiry due to payment failure.", true); Log.d(TAG, "Unexpected expiry due to payment failure.", true);
isDueToPaymentFailure = true; isDueToPaymentFailure = true;
} }
if (activeSubscription.getChargeFailure() != null) {
Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true);
}
} }
} }
@ -320,6 +324,16 @@ public class RefreshOwnProfileJob extends BaseJob {
Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true); Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true);
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration); SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
} else {
Badge badge = SignalStore.donationsValues().getExpiredBadge();
if (badge != null && badge.isSubscription() && remoteHasSubscriptionBadges) {
Log.d(TAG, "Remote has subscription badges. Clearing local expired subscription badge.", true);
SignalStore.donationsValues().setExpiredBadge(null);
} else if (badge != null && badge.isBoost() && remoteHasBoostBadges) {
Log.d(TAG, "Remote has boost badges. Clearing local expired boost badge.", true);
SignalStore.donationsValues().setExpiredBadge(null);
}
} }
if (!remoteHasGiftBadges && localHasGiftBadges) { if (!remoteHasGiftBadges && localHasGiftBadges) {
@ -333,6 +347,9 @@ public class RefreshOwnProfileJob extends BaseJob {
Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true); Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true);
SignalStore.donationsValues().setExpiredGiftBadge(mostRecentExpiration); SignalStore.donationsValues().setExpiredGiftBadge(mostRecentExpiration);
} else if (remoteHasGiftBadges) {
Log.d(TAG, "We have remote gift badges. Clearing local expired gift badge.", true);
SignalStore.donationsValues().setExpiredGiftBadge(null);
} }
boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible); boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);

View file

@ -29,15 +29,19 @@ public class SubscriptionKeepAliveJob extends BaseJob {
private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class); private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class);
private static final long JOB_TIMEOUT = TimeUnit.DAYS.toMillis(3); private static final long JOB_TIMEOUT = TimeUnit.DAYS.toMillis(3);
public static void launchSubscriberIdKeepAliveJobIfNecessary() { public static void enqueueAndTrackTimeIfNecessary() {
long nextLaunchTime = SignalStore.donationsValues().getLastKeepAliveLaunchTime() + TimeUnit.DAYS.toMillis(3); long nextLaunchTime = SignalStore.donationsValues().getLastKeepAliveLaunchTime() + TimeUnit.DAYS.toMillis(3);
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (nextLaunchTime <= now) { if (nextLaunchTime <= now) {
enqueueAndTrackTime(now);
}
}
public static void enqueueAndTrackTime(long now) {
ApplicationDependencies.getJobManager().add(new SubscriptionKeepAliveJob()); ApplicationDependencies.getJobManager().add(new SubscriptionKeepAliveJob());
SignalStore.donationsValues().setLastKeepAliveLaunchTime(now); SignalStore.donationsValues().setLastKeepAliveLaunchTime(now);
} }
}
private SubscriptionKeepAliveJob() { private SubscriptionKeepAliveJob() {
this(new Parameters.Builder() this(new Parameters.Builder()
@ -70,6 +74,12 @@ public class SubscriptionKeepAliveJob extends BaseJob {
@Override @Override
protected void onRun() throws Exception { protected void onRun() throws Exception {
synchronized (SubscriptionReceiptRequestResponseJob.MUTEX) {
doRun();
}
}
private void doRun() throws Exception {
Subscriber subscriber = SignalStore.donationsValues().getSubscriber(); Subscriber subscriber = SignalStore.donationsValues().getSubscriber();
if (subscriber == null) { if (subscriber == null) {
return; return;

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.keyvalue package org.thoughtcrime.securesms.keyvalue
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject import io.reactivex.rxjava3.subjects.Subject
@ -8,6 +9,7 @@ import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.subscription.Subscriber
@ -287,4 +289,63 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
var shouldCancelSubscriptionBeforeNextSubscribeAttempt: Boolean var shouldCancelSubscriptionBeforeNextSubscribeAttempt: Boolean
get() = getBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, false) get() = getBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, false)
set(value) = putBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, value) set(value) = putBoolean(SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT, value)
/**
* Consolidates a bunch of data clears that should occur whenever a user manually cancels their
* subscription:
*
* 1. Clears keep-alive flag
* 1. Clears level operation
* 1. Marks the user as manually cancelled
* 1. Clears out unexpected cancelation state
* 1. Clears expired badge if it is for a subscription
*/
@WorkerThread
fun updateLocalStateForManualCancellation() {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing donation values.")
setLastEndOfPeriod(0L)
clearLevelOperations()
markUserManuallyCancelled()
shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
setUnexpectedSubscriptionCancelationChargeFailure(null)
unexpectedSubscriptionCancelationReason = null
unexpectedSubscriptionCancelationTimestamp = 0L
val expiredBadge = getExpiredBadge()
if (expiredBadge != null && expiredBadge.isSubscription()) {
Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing expired badge.")
setExpiredBadge(null)
}
}
}
/**
* Consolidates a bunch of data clears that should occur whenever a user begins a new subscription:
*
* 1. Manual cancellation marker
* 1. Any set level operations
* 1. Unexpected cancelation flags
* 1. Expired badge, if it is of a subscription
*/
@WorkerThread
fun updateLocalStateForLocalSubscribe() {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
Log.d(TAG, "[updateLocalStateForLocalSubscribe] Clearing donation values.")
clearUserManuallyCancelled()
clearLevelOperations()
shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
setUnexpectedSubscriptionCancelationChargeFailure(null)
unexpectedSubscriptionCancelationReason = null
unexpectedSubscriptionCancelationTimestamp = 0L
val expiredBadge = getExpiredBadge()
if (expiredBadge != null && expiredBadge.isSubscription()) {
Log.d(TAG, "[updateLocalStateForLocalSubscribe] Clearing expired badge.")
setExpiredBadge(null)
}
}
}
} }

View file

@ -9,6 +9,7 @@ import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors; import com.annimon.stream.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.signal.core.util.SetUtil;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.payments.Entropy;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.subscription.Subscriber; import org.thoughtcrime.securesms.subscription.Subscriber;
import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Base64;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord; import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
@ -160,9 +160,7 @@ public final class StorageSyncHelper {
SignalStore.donationsValues().setDisplayBadgesOnProfile(update.getNew().isDisplayBadgesOnProfile()); SignalStore.donationsValues().setDisplayBadgesOnProfile(update.getNew().isDisplayBadgesOnProfile());
if (update.getNew().isSubscriptionManuallyCancelled()) { if (update.getNew().isSubscriptionManuallyCancelled()) {
SignalStore.donationsValues().markUserManuallyCancelled(); SignalStore.donationsValues().updateLocalStateForManualCancellation();
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(null);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(0L);
} else { } else {
SignalStore.donationsValues().clearUserManuallyCancelled(); SignalStore.donationsValues().clearUserManuallyCancelled();
} }

View file

@ -2738,6 +2738,7 @@
<string name="preferences__internal_calling_disable_telecom" translatable="false">Disable Telecom integration</string> <string name="preferences__internal_calling_disable_telecom" translatable="false">Disable Telecom integration</string>
<string name="preferences__internal_badges" translatable="false">Badges</string> <string name="preferences__internal_badges" translatable="false">Badges</string>
<string name="preferences__internal_badges_enqueue_redemption" translatable="false">Enqueue redemption.</string> <string name="preferences__internal_badges_enqueue_redemption" translatable="false">Enqueue redemption.</string>
<string name="preferences__internal_badges_enqueue_keep_alive" translatable="false">Enqueue keep-alive.</string>
<string name="preferences__internal_release_channel" translatable="false">Release channel</string> <string name="preferences__internal_release_channel" translatable="false">Release channel</string>
<string name="preferences__internal_fetch_release_channel" translatable="false">Fetch release channel</string> <string name="preferences__internal_fetch_release_channel" translatable="false">Fetch release channel</string>
<string name="preferences__internal_release_channel_set_last_version" translatable="false">Set last version seen back 10 versions</string> <string name="preferences__internal_release_channel_set_last_version" translatable="false">Set last version seen back 10 versions</string>