mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 02:08:40 +00:00
Add UX for handling CDS rate limits.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user