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

@@ -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"
}
}