Ensure rate limit dialog appears during calls.

This commit is contained in:
Alex Hart
2024-10-23 14:15:42 -03:00
committed by GitHub
parent 6673293e29
commit 9fa04e03fd
11 changed files with 253 additions and 91 deletions

View File

@@ -0,0 +1,128 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.ratelimit
import android.content.Context
import androidx.annotation.WorkerThread
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.SubmitRateLimitPushChallengeJob.SuccessEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Reusable ProofRequiredException handling code.
*/
object ProofRequiredExceptionHandler {
private val TAG = Log.tag(ProofRequiredExceptionHandler::class)
private val PUSH_CHALLENGE_TIMEOUT: Duration = 10.seconds
/**
* Handles the given exception, updating state as necessary.
*/
@JvmStatic
@WorkerThread
fun handle(context: Context, proofRequired: ProofRequiredException, recipient: Recipient?, threadId: Long, messageId: Long): Result {
Log.w(TAG, "[Proof Required] Options: ${proofRequired.options}")
try {
if (ProofRequiredException.Option.PUSH_CHALLENGE in proofRequired.options) {
AppDependencies.signalServiceAccountManager.requestRateLimitPushChallenge()
Log.i(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to $PUSH_CHALLENGE_TIMEOUT ms.")
val success = PushChallengeRequest(PUSH_CHALLENGE_TIMEOUT).blockUntilSuccess()
if (success) {
Log.i(TAG, "Successfully responded to a push challenge. Retrying message send.")
return Result.RETRY_NOW
} else {
Log.w(TAG, "Failed to respond to the push challeng in time. Falling back.")
}
}
} catch (e: NonSuccessfulResponseCodeException) {
Log.w(TAG, "[Proof Required] Could not request a push challenge (${e.code}). Falling back.", e)
} catch (e: IOException) {
Log.w(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later.")
return Result.RETRY_LATER
}
if (messageId > 0) {
Log.w(TAG, "[Proof Required] Marking message as rate-limited. (id: $messageId, thread: $threadId)")
SignalDatabase.messages.markAsRateLimited(messageId)
}
if (ProofRequiredException.Option.CAPTCHA in proofRequired.options) {
Log.i(TAG, "[Proof Required] CAPTCHA required.")
SignalStore.rateLimit.markNeedsRecaptcha(proofRequired.token)
if (recipient != null && messageId > -1L) {
val groupReply: ParentStoryId.GroupReply? = SignalDatabase.messages.getParentStoryIdForGroupReply(messageId)
AppDependencies.messageNotifier.notifyProofRequired(context, recipient, ConversationId.fromThreadAndReply(threadId, groupReply))
} else {
Log.w(TAG, "[Proof Required] No recipient! Couldn't notify.")
}
}
return Result.RETHROW
}
enum class Result {
/**
* The challenge was successful and the message send can be retried immediately.
*/
RETRY_NOW,
/**
* The challenge failed due to a network error and should be scheduled to retry with some offset.
*/
RETRY_LATER,
/**
* The caller should rethrow the original error.
*/
RETHROW;
fun isRetry() = this != RETHROW
}
private class PushChallengeRequest(val timeout: Duration) {
private val latch = CountDownLatch(1)
private val eventBus = EventBus.getDefault()
fun blockUntilSuccess(): Boolean {
eventBus.register(this)
return try {
latch.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
} catch (e: InterruptedException) {
Log.w(TAG, "[Proof Required] Interrupted?", e)
false
} finally {
eventBus.unregister(this)
}
}
@Subscribe(threadMode = ThreadMode.POSTING)
fun onSuccessReceived(event: SuccessEvent) {
Log.i(TAG, "[Proof Required] Received a successful result!")
latch.countDown()
}
}
}

View File

@@ -10,8 +10,11 @@ import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import androidx.activity.result.contract.ActivityResultContract;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
@@ -20,7 +23,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Util;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
@@ -36,10 +38,6 @@ public class RecaptchaProofActivity extends PassphraseRequiredActivity {
private final DynamicTheme dynamicTheme = new DynamicTheme();
public static @NonNull Intent getIntent(@NonNull Context context) {
return new Intent(context, RecaptchaProofActivity.class);
}
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
@@ -120,6 +118,7 @@ public class RecaptchaProofActivity extends PassphraseRequiredActivity {
if (result.clearState) {
Log.i(TAG, "Considering the response sufficient to clear the slate.");
SignalStore.rateLimit().onProofAccepted();
setResult(RESULT_OK);
}
if (!result.success) {
@@ -140,4 +139,17 @@ public class RecaptchaProofActivity extends PassphraseRequiredActivity {
this.success = success;
}
}
public static class RecaptchaProofContract extends ActivityResultContract<Void, Boolean> {
@Override
public @NonNull Intent createIntent(@NonNull Context context, Void unused) {
return new Intent(context, RecaptchaProofActivity.class);
}
@Override
public Boolean parseResult(int resultCode, @Nullable Intent intent) {
return resultCode == RESULT_OK;
}
}
}

View File

@@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.ratelimit;
import android.app.Activity;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
@@ -24,6 +26,8 @@ public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFr
private static final String TAG = Log.tag(RecaptchaProofBottomSheetFragment.class);
private ActivityResultLauncher<Void> launcher;
public static void show(@NonNull FragmentManager manager) {
new RecaptchaProofBottomSheetFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
}
@@ -38,11 +42,25 @@ public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFr
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.recaptcha_required_bottom_sheet, container, false);
view.findViewById(R.id.recaptcha_sheet_ok_button).setOnClickListener(v -> {
Activity activity = requireActivity();
final Callback callback;
if (activity instanceof Callback) {
callback = (Callback) activity;
} else {
callback = null;
}
launcher = registerForActivityResult(new RecaptchaProofActivity.RecaptchaProofContract(), (isOk) -> {
if (isOk && callback != null) {
callback.onProofCompleted();
}
dismissAllowingStateLoss();
startActivity(RecaptchaProofActivity.getIntent(requireContext()));
});
view.findViewById(R.id.recaptcha_sheet_ok_button).setOnClickListener(v -> launcher.launch(null));
return view;
}
@@ -62,4 +80,12 @@ public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFr
Log.i(TAG, "Ignoring repeat show.");
}
}
/**
* Optional callback interface to be invoked when the user successfully completes a push challenge.
* This is expected to be implemented on the activity which is displaying this fragment.
*/
public interface Callback {
void onProofCompleted();
}
}