Add UX for handling CDS rate limits.

This commit is contained in:
Greyson Parrelli
2022-11-10 10:51:21 -05:00
parent 8eb3a1906e
commit c563ef27da
21 changed files with 570 additions and 18 deletions

View File

@@ -51,7 +51,9 @@ public class EmojiEditText extends AppCompatEditText {
}
});
EditTextExtensionsKt.setIncognitoKeyboardEnabled(this, TextSecurePreferences.isIncognitoKeyboardEnabled(context));
if (!isInEditMode()) {
EditTextExtensionsKt.setIncognitoKeyboardEnabled(this, TextSecurePreferences.isIncognitoKeyboardEnabled(context));
}
}
public void insertEmoji(String emoji) {

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.days
/**
* Reminder shown when CDS is in a permanent error state, preventing us from doing a sync.
*/
class CdsPermanentErrorReminder(context: Context) : Reminder(null, context.getString(R.string.reminder_cds_permanent_error_body)) {
init {
addAction(
Action(
context.getString(R.string.reminder_cds_permanent_error_learn_more),
R.id.reminder_action_cds_permanent_error_learn_more
)
)
}
override fun isDismissable(): Boolean {
return false
}
override fun getImportance(): Importance {
return Importance.ERROR
}
companion object {
/**
* Even if we're not truly "permanently blocked", if the time until we're unblocked is long enough, we'd rather show the permanent error message than
* telling the user to wait for 3 months or something.
*/
val PERMANENT_TIME_CUTOFF = 30.days.inWholeMilliseconds
@JvmStatic
fun isEligible(): Boolean {
val timeUntilUnblock = SignalStore.misc().cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc().isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
}
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Reminder shown when CDS is rate-limited, preventing us from temporarily doing a refresh.
*/
class CdsTemporyErrorReminder(context: Context) : Reminder(null, context.getString(R.string.reminder_cds_warning_body)) {
init {
addAction(
Action(
context.getString(R.string.reminder_cds_warning_learn_more),
R.id.reminder_action_cds_temporary_error_learn_more
)
)
}
override fun isDismissable(): Boolean {
return false
}
override fun getImportance(): Importance {
return Importance.ERROR
}
companion object {
@JvmStatic
fun isEligible(): Boolean {
val timeUntilUnblock = SignalStore.misc().cdsBlockedUtil - System.currentTimeMillis()
return SignalStore.misc().isCdsBlocked && timeUntilUnblock < CdsPermanentErrorReminder.PERMANENT_TIME_CUTOFF
}
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.contacts.sync
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.databinding.CdsPermanentErrorBottomSheetBinding
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Bottom sheet shown when CDS is in a permanent error state, preventing us from doing a sync.
*/
class CdsPermanentErrorBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
private lateinit var binding: CdsPermanentErrorBottomSheetBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = CdsPermanentErrorBottomSheetBinding.inflate(inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)), container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.learnMoreButton.setOnClickListener {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007319011#android_contacts_error")
}
binding.settingsButton.setOnClickListener {
val intent = Intent().apply {
action = Intent.ACTION_VIEW
data = android.provider.ContactsContract.Contacts.CONTENT_URI
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.CdsPermanentErrorBottomSheet_no_contacts_toast, Toast.LENGTH_SHORT).show()
}
}
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = CdsPermanentErrorBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.contacts.sync
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.databinding.CdsTemporaryErrorBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import kotlin.time.Duration.Companion.milliseconds
/**
* Bottom sheet shown when CDS is rate-limited, preventing us from temporarily doing a refresh.
*/
class CdsTemporaryErrorBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
private lateinit var binding: CdsTemporaryErrorBottomSheetBinding
override val peekHeightPercentage: Float = 0.75f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = CdsTemporaryErrorBottomSheetBinding.inflate(inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)), container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val days: Int = (SignalStore.misc().cdsBlockedUtil - System.currentTimeMillis()).milliseconds.inWholeDays.toInt()
binding.timeText.text = resources.getQuantityString(R.plurals.CdsTemporaryErrorBottomSheet_body1, days, days)
binding.learnMoreButton.setOnClickListener {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/360007319011#android_contacts_error")
}
binding.settingsButton.setOnClickListener {
val intent = Intent().apply {
action = Intent.ACTION_VIEW
data = android.provider.ContactsContract.Contacts.CONTENT_URI
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, R.string.CdsPermanentErrorBottomSheet_no_contacts_toast, Toast.LENGTH_SHORT).show()
}
}
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = CdsTemporaryErrorBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -17,13 +17,17 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException
import org.whispersystems.signalservice.api.services.CdsiV2Service
import java.io.IOException
import java.util.Optional
import java.util.concurrent.Callable
import java.util.concurrent.Future
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
/**
* Performs the CDS refresh using the V2 interface (either CDSH or CDSI) that returns both PNIs and ACIs.
@@ -113,6 +117,12 @@ object ContactDiscoveryRefreshV2 {
return ContactDiscovery.RefreshResult(emptySet(), emptyMap())
}
if (newE164s.size > FeatureFlags.cdsHardLimit()) {
Log.w(TAG, "[$tag] Number of new contacts (${newE164s.size.roundedString()} > hard limit (${FeatureFlags.cdsHardLimit()}! Failing and marking ourselves as permanently blocked.")
SignalStore.misc().markCdsPermanentlyBlocked()
throw IOException("New contacts over the CDS hard limit!")
}
val token: ByteArray? = if (previousE164s.isNotEmpty() && !isPartialRefresh) SignalStore.misc().cdsToken else null
stopwatch.split("preamble")
@@ -137,14 +147,22 @@ object ContactDiscoveryRefreshV2 {
}
stopwatch.split("cds-db")
}
} catch (e: NonSuccessfulResponseCodeException) {
if (e.code == 4101) {
Log.w(TAG, "Our token was invalid! Only thing we can do now is clear our local state :(")
SignalStore.misc().cdsToken = null
SignalDatabase.cds.clearAll()
}
} catch (e: CdsiResourceExhaustedException) {
Log.w(TAG, "CDS resource exhausted! Can try again in ${e.retryAfterSeconds} seconds.")
SignalStore.misc().cdsBlockedUtil = System.currentTimeMillis() + e.retryAfterSeconds.seconds.inWholeMilliseconds
throw e
} catch (e: CdsiInvalidTokenException) {
Log.w(TAG, "Our token was invalid! Only thing we can do now is clear our local state :(")
SignalStore.misc().cdsToken = null
SignalDatabase.cds.clearAll()
throw e
}
if (!isPartialRefresh && SignalStore.misc().isCdsBlocked) {
Log.i(TAG, "Successfully made a request while blocked -- clearing blocked state.")
SignalStore.misc().clearCdsBlocked()
}
Log.d(TAG, "[$tag] Used ${response.quotaUsedDebugOnly} quota.")
stopwatch.split("network-post-token")
@@ -264,4 +282,9 @@ object ContactDiscoveryRefreshV2 {
}
.toSet()
}
private fun Int.roundedString(): String {
val nearestThousand = (this.toDouble() / 1000).roundToInt()
return "~${nearestThousand}k"
}
}

View File

@@ -94,6 +94,8 @@ import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
import org.thoughtcrime.securesms.components.reminder.CdsPermanentErrorReminder;
import org.thoughtcrime.securesms.components.reminder.CdsTemporyErrorReminder;
import org.thoughtcrime.securesms.components.reminder.DozeReminder;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.OutdatedBuildReminder;
@@ -106,6 +108,8 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet;
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
@@ -616,6 +620,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void onReminderAction(@IdRes int reminderActionId) {
if (reminderActionId == R.id.reminder_action_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext());
} else if (reminderActionId == R.id.reminder_action_cds_temporary_error_learn_more) {
CdsTemporaryErrorBottomSheet.show(getChildFragmentManager());
} else if (reminderActionId == R.id.reminder_action_cds_permanent_error_learn_more) {
CdsPermanentErrorBottomSheet.show(getChildFragmentManager());
}
}
@@ -875,6 +883,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
return Optional.of((new PushRegistrationReminder(context)));
} else if (DozeReminder.isEligible(context)) {
return Optional.of(new DozeReminder(context));
} else if (CdsTemporyErrorReminder.isEligible()) {
return Optional.of(new CdsTemporyErrorReminder(context));
} else if (CdsPermanentErrorReminder.isEligible()) {
return Optional.of(new CdsPermanentErrorReminder(context));
} else {
return Optional.<Reminder>empty();
}

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNum
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
public final class MiscellaneousValues extends SignalStoreValues {
@@ -23,6 +24,7 @@ public final class MiscellaneousValues extends SignalStoreValues {
private static final String CENSORSHIP_SERVICE_REACHABLE = "misc.censorship.service_reachable";
private static final String LAST_GV2_PROFILE_CHECK_TIME = "misc.last_gv2_profile_check_time";
private static final String CDS_TOKEN = "misc.cds_token";
private static final String CDS_BLOCKED_UNTIL = "misc.cds_blocked_until";
private static final String LAST_FCM_FOREGROUND_TIME = "misc.last_fcm_foreground_time";
private static final String LAST_FOREGROUND_TIME = "misc.last_foreground_time";
private static final String PNI_INITIALIZED_DEVICES = "misc.pni_initialized_devices";
@@ -166,6 +168,42 @@ public final class MiscellaneousValues extends SignalStoreValues {
.commit();
}
/**
* Marks the time at which we think the next CDS request will succeed. This should be taken from the service response.
*/
public void setCdsBlockedUtil(long time) {
putLong(CDS_BLOCKED_UNTIL, time);
}
/**
* Indicates that a CDS request will never succeed at the current contact count.
*/
public void markCdsPermanentlyBlocked() {
putLong(CDS_BLOCKED_UNTIL, Long.MAX_VALUE);
}
/**
* Clears any rate limiting state related to CDS.
*/
public void clearCdsBlocked() {
setCdsBlockedUtil(0);
}
/**
* Whether or not we expect the next CDS request to succeed.
*/
public boolean isCdsBlocked() {
return getCdsBlockedUtil() > 0;
}
/**
* This represents the next time we think we'll be able to make a successful CDS request. If it is before this time, we expect the request will fail
* (assuming the user still has the same number of new E164s).
*/
public long getCdsBlockedUtil() {
return getLong(CDS_BLOCKED_UNTIL, 0);
}
public long getLastFcmForegroundServiceTime() {
return getLong(LAST_FCM_FOREGROUND_TIME, 0);
}

View File

@@ -25,8 +25,15 @@ public class DirectoryRefreshListener extends PersistentAlarmManagerListener {
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(true));
}
long interval = TimeUnit.SECONDS.toMillis(FeatureFlags.cdsRefreshIntervalSeconds());
long newTime = System.currentTimeMillis() + interval;
long newTime;
if (SignalStore.misc().isCdsBlocked()) {
newTime = Math.min(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(6),
SignalStore.misc().getCdsBlockedUtil());
} else {
newTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(FeatureFlags.cdsRefreshIntervalSeconds());
TextSecurePreferences.setDirectoryRefreshTime(context, newTime);
}
TextSecurePreferences.setDirectoryRefreshTime(context, newTime);

View File

@@ -106,6 +106,7 @@ public final class FeatureFlags {
public static final String GOOGLE_PAY_DISABLED_REGIONS = "global.donations.gpayDisabledRegions";
public static final String CREDIT_CARD_DISABLED_REGIONS = "global.donations.ccDisabledRegions";
public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -163,7 +164,9 @@ public final class FeatureFlags {
KEEP_MUTED_CHATS_ARCHIVED,
GOOGLE_PAY_DISABLED_REGIONS,
CREDIT_CARD_DISABLED_REGIONS,
PAYPAL_DISABLED_REGIONS
PAYPAL_DISABLED_REGIONS,
KEEP_MUTED_CHATS_ARCHIVED,
CDS_HARD_LIMIT
);
@VisibleForTesting
@@ -227,7 +230,8 @@ public final class FeatureFlags {
SMS_EXPORT_MEGAPHONE_DELAY_DAYS,
CREDIT_CARD_PAYMENTS,
PAYMENTS_REQUEST_ACTIVATE_FLOW,
KEEP_MUTED_CHATS_ARCHIVED
KEEP_MUTED_CHATS_ARCHIVED,
CDS_HARD_LIMIT
);
/**
@@ -589,6 +593,13 @@ public final class FeatureFlags {
return getString(PAYPAL_DISABLED_REGIONS, "*");
}
/**
* If the user has more than this number of contacts, the CDS request will certainly be rejected, so we must fail.
*/
public static int cdsHardLimit() {
return getInteger(CDS_HARD_LIMIT, 50_000);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);