diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java index 35687c124..ab234b81e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiEditText.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsPermanentErrorReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsPermanentErrorReminder.kt new file mode 100644 index 000000000..d8b6d206d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsPermanentErrorReminder.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsTemporyErrorReminder.kt b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsTemporyErrorReminder.kt new file mode 100644 index 000000000..06854f94f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/CdsTemporyErrorReminder.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsPermanentErrorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsPermanentErrorBottomSheet.kt new file mode 100644 index 000000000..902cde152 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsPermanentErrorBottomSheet.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsTemporaryErrorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsTemporaryErrorBottomSheet.kt new file mode 100644 index 000000000..94ec589cd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/CdsTemporaryErrorBottomSheet.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryRefreshV2.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryRefreshV2.kt index 0c915f743..c62bb6083 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryRefreshV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscoveryRefreshV2.kt @@ -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" + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 6ce12c600..44e475de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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.empty(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index 96b907f12..f14ac274b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java index 46c9224be..f12b81098 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectoryRefreshListener.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 0cee57cb8..fe5e5a807 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable/ic_error_triangle_24.xml b/app/src/main/res/drawable/ic_error_triangle_24.xml new file mode 100644 index 000000000..f63ccc3fc --- /dev/null +++ b/app/src/main/res/drawable/ic_error_triangle_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/cds_permanent_error_bottom_sheet.xml b/app/src/main/res/layout/cds_permanent_error_bottom_sheet.xml new file mode 100644 index 000000000..146d1f9b1 --- /dev/null +++ b/app/src/main/res/layout/cds_permanent_error_bottom_sheet.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cds_temporary_error_bottom_sheet.xml b/app/src/main/res/layout/cds_temporary_error_bottom_sheet.xml new file mode 100644 index 000000000..c1e4f86d2 --- /dev/null +++ b/app/src/main/res/layout/cds_temporary_error_bottom_sheet.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/contact_search_section_header.xml b/app/src/main/res/layout/contact_search_section_header.xml index 02a444eec..1938569f8 100644 --- a/app/src/main/res/layout/contact_search_section_header.xml +++ b/app/src/main/res/layout/contact_search_section_header.xml @@ -22,7 +22,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0dbacc9c9..603d3680f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3201,6 +3201,14 @@ Upgrade your communication experience. Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible. %1$d%% + + Signal\'s private contact discovery temporarily can\'t process your phone\'s contacts. + + Learn more + + Signal\'s private contact discovery can\'t process your phone\'s contacts. + + Learn more Save @@ -5523,6 +5531,31 @@ Cancelling… + + Too many contacts have been processed + + + Another attempt to process your contacts will be made within %1$d day. + Another attempt to process your contacts will be made within %1$d days. + + + To resolve this issue sooner, you can consider removing contacts or accounts on your phone that are syncing a lot of contacts. + + Open contacts + + No contacts app found + + + Your contacts can\'t be processed + + The number of contacts on your phone exceeds the limit Signal can process. To find contacts on Signal, consider removing contacts or accounts on your phone that are syncing a lot of contacts. + + Learn more + + Open contacts + + No contacts app found + diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidArgumentException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidArgumentException.java new file mode 100644 index 000000000..1fd5e02ae --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidArgumentException.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.api.push.exceptions; + + +/** + * Indicates that something about our request was wrong. Could be: + * - Over 50k new contacts + * - Missing version byte prefix + * - Missing credentials + * - E164s are not a multiple of 8 bytes + * - Something else? + */ +public class CdsiInvalidArgumentException extends NonSuccessfulResponseCodeException { + public CdsiInvalidArgumentException() { + super(4003); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidTokenException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidTokenException.java new file mode 100644 index 000000000..e1bbe4bae --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiInvalidTokenException.java @@ -0,0 +1,11 @@ +package org.whispersystems.signalservice.api.push.exceptions; + + +/** + * Indicates that you provided a bad token to CDSI. + */ +public class CdsiInvalidTokenException extends NonSuccessfulResponseCodeException { + public CdsiInvalidTokenException() { + super(4101); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiResourceExhaustedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiResourceExhaustedException.java new file mode 100644 index 000000000..13ee02ff1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/CdsiResourceExhaustedException.java @@ -0,0 +1,18 @@ +package org.whispersystems.signalservice.api.push.exceptions; + +/** + * A 4008 responses from CDSI indicating we've exhausted our quota. + */ +public class CdsiResourceExhaustedException extends NonSuccessfulResponseCodeException { + + private final int retryAfterSeconds; + + public CdsiResourceExhaustedException(int retryAfterSeconds) { + super(4008); + this.retryAfterSeconds = retryAfterSeconds; + } + + public int getRetryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiSocket.java index 925711b17..a9e437781 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiSocket.java @@ -8,7 +8,10 @@ import org.signal.libsignal.cds2.Cds2CommunicationFailureException; import org.signal.libsignal.protocol.logging.Log; import org.signal.libsignal.protocol.util.Pair; import org.whispersystems.signalservice.api.push.TrustStore; +import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException; +import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.util.Tls12SocketFactory; import org.whispersystems.signalservice.api.util.TlsProxySocketFactory; import org.whispersystems.signalservice.internal.configuration.SignalProxy; @@ -22,9 +25,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; -import java.time.Duration; import java.time.Instant; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -90,6 +93,9 @@ final class CdsiSocket { .addHeader("Authorization", basicAuth(username, password)) .build(); + AtomicInteger retryAfterSeconds = new AtomicInteger(0); + + WebSocket webSocket = okhttp.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket webSocket, Response response) { @@ -128,6 +134,12 @@ final class CdsiSocket { case WAITING_FOR_TOKEN: ClientResponse tokenResponse = ClientResponse.parseFrom(client.establishedRecv(bytes.toByteArray())); + + if (tokenResponse.getRetryAfterSecs() > 0) { + Log.w(TAG, "Got a retry-after (" + tokenResponse.getRetryAfterSecs() + "), meaning we hit the rate limit. (WAITING_FOR_TOKEN)"); + throw new CdsiResourceExhaustedException(tokenResponse.getRetryAfterSecs()); + } + if (tokenResponse.getToken().isEmpty()) { throw new IOException("No token! Cannot continue!"); } @@ -143,6 +155,13 @@ final class CdsiSocket { break; case WAITING_FOR_RESPONSE: + ClientResponse dataResponse = ClientResponse.parseFrom(client.establishedRecv(bytes.toByteArray())); + + if (dataResponse.getRetryAfterSecs() > 0) { + Log.w(TAG, "Got a retry-after (" + dataResponse.getRetryAfterSecs() + "), meaning we hit the rate limit. (WAITING_FOR_RESPONSE)"); + throw new CdsiResourceExhaustedException(dataResponse.getRetryAfterSecs()); + } + emitter.onNext(ClientResponse.parseFrom(client.establishedRecv(bytes.toByteArray()))); break; @@ -172,7 +191,13 @@ final class CdsiSocket { Log.w(TAG, "Remote side is closing with non-normal code " + code); webSocket.close(1000, "Remote closed with code " + code); stage.set(Stage.FAILED); - emitter.tryOnError(new NonSuccessfulResponseCodeException(code)); + if (code == 4003) { + emitter.tryOnError(new CdsiInvalidArgumentException()); + } else if (code == 4101) { + emitter.tryOnError(new CdsiInvalidTokenException()); + } else { + emitter.tryOnError(new NonSuccessfulResponseCodeException(code)); + } } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiV2Service.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiV2Service.java index 9b6e2d670..0f8c7acee 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiV2Service.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/CdsiV2Service.java @@ -40,14 +40,14 @@ public final class CdsiV2Service { private static final UUID EMPTY_UUID = new UUID(0, 0); private static final int RESPONSE_ITEM_SIZE = 8 + 16 + 16; // 1 uint64 + 2 UUIDs - private final CdsiSocket cdshSocket; + private final CdsiSocket cdsiSocket; public CdsiV2Service(SignalServiceConfiguration configuration, String mrEnclave) { - this.cdshSocket = new CdsiSocket(configuration, mrEnclave); + this.cdsiSocket = new CdsiSocket(configuration, mrEnclave); } public Single> getRegisteredUsers(String username, String password, Request request, Consumer tokenSaver) { - return cdshSocket + return cdsiSocket .connect(username, password, buildClientRequest(request), tokenSaver) .map(CdsiV2Service::parseEntries) .collect(Collectors.toList())