diff --git a/app/build.gradle b/app/build.gradle index 974288342d..e4876f7e81 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,6 +136,7 @@ android { buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\"" buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}" buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\"" + buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"" ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' @@ -285,6 +286,7 @@ android { buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"" buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\"" buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"" + buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ffca6af7c7..3d0ae5ac16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -573,6 +573,10 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" android:launchMode="singleTask" /> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 115718107f..9fda047ecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -58,6 +58,7 @@ import org.thoughtcrime.securesms.migrations.ApplicationMigrations; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; +import org.thoughtcrime.securesms.ratelimit.RateLimitUtil; import org.thoughtcrime.securesms.registration.RegistrationUtil; import org.thoughtcrime.securesms.ringrtc.RingRtcLogger; import org.thoughtcrime.securesms.service.DirectoryRefreshListener; @@ -156,6 +157,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop()) .addNonBlocking(EmojiSource::refresh) .addNonBlocking(DownloadLatestEmojiDataJob::scheduleIfNecessary) + .addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this)) .addPostRender(this::initializeExpiringMessageManager) .execute(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 5437c267ca..cee974ab35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -64,6 +64,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable { void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms); void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); + void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord); void onRegisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); void onUnregisterVoiceNoteCallbacks(@NonNull Observer onPlaybackStartObserver); void onVoiceNotePause(@NonNull Uri uri); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java index 23c371156c..d57af4ae63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AlertView.java @@ -69,4 +69,10 @@ public class AlertView extends LinearLayout { approvalIndicator.setVisibility(View.GONE); failedIndicator.setVisibility(View.VISIBLE); } + + public void setRateLimited() { + this.setVisibility(View.VISIBLE); + approvalIndicator.setVisibility(View.VISIBLE); + failedIndicator.setVisibility(View.GONE); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index a5cdefa05c..1e57fffc9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -161,6 +161,8 @@ public class ConversationItemFooter extends LinearLayout { dateView.setText(errorMsg); } else if (messageRecord.isPendingInsecureSmsFallback()) { dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted); + } else if (messageRecord.isRateLimited()) { + dateView.setText(R.string.ConversationItem_send_paused); } else { dateView.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 128c1e8c05..43feb07752 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -228,6 +228,8 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView; import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment; import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofActivity; +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.LiveRecipient; @@ -579,6 +581,10 @@ public class ConversationActivity extends PassphraseRequiredActivity setVisibleThread(threadId); ConversationUtil.refreshRecipientShortcuts(); + + if (SignalStore.rateLimit().needsRecaptcha()) { + RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager()); + } } @Override @@ -2200,6 +2206,7 @@ public class ConversationActivity extends PassphraseRequiredActivity this.viewModel.setArgs(args); this.viewModel.getWallpaper().observe(this, this::updateWallpaper); + this.viewModel.getEvents().observe(this, this::onViewModelEvent); } private void initializeGroupViewModel() { @@ -2973,6 +2980,14 @@ public class ConversationActivity extends PassphraseRequiredActivity } } + private void onViewModelEvent(@NonNull ConversationViewModel.Event event) { + if (event == ConversationViewModel.Event.SHOW_RECAPTCHA) { + RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager()); + } else { + throw new AssertionError("Unexpected event!"); + } + } + private void updateLinkPreviewState() { if (SignalStore.settings().isLinkPreviewsEnabled() && isSecureText && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) { linkPreviewViewModel.onEnabled(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2cf89efb92..fd86ff96e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -124,6 +124,7 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; @@ -1503,6 +1504,11 @@ public class ConversationFragment extends LoggingFragment { listener.onMessageWithErrorClicked(messageRecord); } + @Override + public void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord) { + RecaptchaProofBottomSheetFragment.show(getChildFragmentManager()); + } + @Override public void onVoiceNotePause(@NonNull Uri uri) { voiceNoteMediaController.pausePlayback(uri); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 733f0a79cf..e9b5f39ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -97,6 +97,7 @@ import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; import org.thoughtcrime.securesms.jobs.MmsDownloadJob; import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -1054,6 +1055,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo alertView.setFailed(); } else if (messageRecord.isPendingInsecureSmsFallback()) { alertView.setPendingApproval(); + } else if (messageRecord.isRateLimited()) { + alertView.setRateLimited(); } else { alertView.setNone(); } @@ -1166,7 +1169,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(context, locale, next.get().getTimestamp(), current.getTimestamp()); if (forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() || - current.isFailed() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread)) + current.isFailed() || current.isRateLimited() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread)) { ConversationItemFooter activeFooter = getActiveFooter(current); activeFooter.setVisibility(VISIBLE); @@ -1211,10 +1214,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } private boolean shouldInterceptClicks(MessageRecord messageRecord) { - return batchSelected.isEmpty() && - ((messageRecord.isFailed() && !messageRecord.isMmsNotification()) || - messageRecord.isPendingInsecureSmsFallback() || - messageRecord.isBundleKeyExchange()); + return batchSelected.isEmpty() && + ((messageRecord.isFailed() && !messageRecord.isMmsNotification()) || + (messageRecord.isRateLimited() && SignalStore.rateLimit().needsRecaptcha()) || + messageRecord.isPendingInsecureSmsFallback() || + messageRecord.isBundleKeyExchange()); } @SuppressLint("SetTextI18n") @@ -1682,6 +1686,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (eventListener != null) { eventListener.onMessageWithErrorClicked(messageRecord); } + } else if (messageRecord.isRateLimited() && SignalStore.rateLimit().needsRecaptcha()) { + if (eventListener != null) { + eventListener.onMessageWithRecaptchaNeededClicked(messageRecord); + } } else if (!messageRecord.isOutgoing() && messageRecord.isIdentityMismatchFailure()) { handleApproveIdentity(); } else if (messageRecord.isPendingInsecureSmsFallback()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 957e5b87b1..950ddfc724 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -10,6 +10,9 @@ import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.logging.Log; import org.signal.paging.PagedData; import org.signal.paging.PagingConfig; @@ -19,8 +22,10 @@ import org.thoughtcrime.securesms.database.DatabaseObserver; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaRepository; +import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; import org.whispersystems.libsignal.util.Pair; @@ -28,7 +33,7 @@ import org.whispersystems.libsignal.util.Pair; import java.util.List; import java.util.Objects; -class ConversationViewModel extends ViewModel { +public class ConversationViewModel extends ViewModel { private static final String TAG = Log.tag(ConversationViewModel.class); @@ -46,6 +51,7 @@ class ConversationViewModel extends ViewModel { private final DatabaseObserver.Observer messageObserver; private final MutableLiveData recipientId; private final LiveData wallpaper; + private final SingleLiveEvent events; private ConversationIntents.Args args; private int jumpToPosition; @@ -59,6 +65,7 @@ class ConversationViewModel extends ViewModel { this.showScrollButtons = new MutableLiveData<>(false); this.hasUnreadMentions = new MutableLiveData<>(false); this.recipientId = new MutableLiveData<>(); + this.events = new SingleLiveEvent<>(); this.pagingController = new ProxyPagingController(); this.messageObserver = pagingController::onDataInvalidated; @@ -108,6 +115,8 @@ class ConversationViewModel extends ViewModel { wallpaper = Transformations.distinctUntilChanged(Transformations.map(Transformations.switchMap(recipientId, id -> Recipient.live(id).getLiveData()), Recipient::getWallpaper)); + + EventBus.getDefault().register(this); } void onAttachmentKeyboardOpen() { @@ -144,6 +153,10 @@ class ConversationViewModel extends ViewModel { return wallpaper; } + @NonNull LiveData getEvents() { + return events; + } + void setHasUnreadMentions(boolean hasUnreadMentions) { this.hasUnreadMentions.setValue(hasUnreadMentions); } @@ -184,10 +197,20 @@ class ConversationViewModel extends ViewModel { return Objects.requireNonNull(args); } + @Subscribe(threadMode = ThreadMode.POSTING) + public void onRecaptchaRequiredEvent(@NonNull RecaptchaRequiredEvent event) { + events.postValue(Event.SHOW_RECAPTCHA); + } + @Override protected void onCleared() { super.onCleared(); ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver); + EventBus.getDefault().unregister(this); + } + + enum Event { + SHOW_RECAPTCHA } static class Factory extends ViewModelProvider.NewInstanceFactory { 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 cd5c3ad733..42eb45013c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -116,6 +116,8 @@ import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity; import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs; import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable; import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofActivity; +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.sms.MessageSender; @@ -283,6 +285,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode } SignalProxyUtil.startListeningToWebsocket(); + + if (SignalStore.rateLimit().needsRecaptcha()) { + Log.i(TAG, "Recaptcha required."); + RecaptchaProofBottomSheetFragment.show(getChildFragmentManager()); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 40bb9d747c..6e38aea50c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -89,6 +89,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage(); public abstract boolean isSent(long messageId); public abstract List getProfileChangeDetailsRecords(long threadId, long afterTimestamp); + public abstract Set getAllRateLimitedMessageIds(); public abstract void markExpireStarted(long messageId); public abstract void markExpireStarted(long messageId, long startTime); @@ -101,6 +102,8 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract void markAsInsecure(long id); public abstract void markAsPush(long id); public abstract void markAsForcedSms(long id); + public abstract void markAsRateLimited(long id); + public abstract void clearRateLimitStatus(Collection ids); public abstract void markAsDecryptFailed(long id); public abstract void markAsDecryptDuplicate(long id); public abstract void markAsNoSession(long id); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index ae58cf6000..d17507694f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -785,6 +785,30 @@ public class MmsDatabase extends MessageDatabase { notifyConversationListeners(threadId); } + @Override + public void markAsRateLimited(long messageId) { + long threadId = getThreadIdForMessage(messageId); + updateMailboxBitmask(messageId, 0, Types.MESSAGE_RATE_LIMITED_BIT, Optional.of(threadId)); + notifyConversationListeners(threadId); + } + + @Override + public void clearRateLimitStatus(@NonNull Collection ids) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (long id : ids) { + long threadId = getThreadIdForMessage(id); + updateMailboxBitmask(id, Types.MESSAGE_RATE_LIMITED_BIT, 0, Optional.of(threadId)); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + @Override public void markAsPendingInsecureSmsFallback(long messageId) { long threadId = getThreadIdForMessage(messageId); @@ -1708,6 +1732,22 @@ public class MmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public Set getAllRateLimitedMessageIds() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = "(" + MESSAGE_BOX + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0"; + + Set ids = new HashSet<>(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) { + while (cursor.moveToNext()) { + ids.add(CursorUtil.requireLong(cursor, ID)); + } + } + + return ids; + } + @Override void deleteThreads(@NonNull Set threadIds) { Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 8e657ee292..3ab69763d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -28,7 +28,34 @@ public interface MmsSmsColumns { public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; public static final String REMOTE_DELETED = "remote_deleted"; + /** + * For storage efficiency, all types are stored within a single 64-bit integer column in the + * database. There are various areas reserved for different classes of data. + * + * When carving out a new area, if it's storing a bunch of mutually-exclusive flags (like in + * {@link #BASE_TYPE_MASK}, you should store integers in that area. If multiple flags can be set + * within a category, you'll have to store them as bits. Just keep in mind that storing as bits + * means we can store less data (i.e. 4 bits can store 16 exclusive values, or 4 non-exclusive + * values). This was not always followed in the past, and now we've wasted some space. + * + * Note: We technically could use up to 64 bits, but {@link #TOTAL_MASK} is currently just set to + * look at 32. Theoretically if we needed more bits, we could just use them and expand the size of + * {@link #TOTAL_MASK}. + * + *
+   *      _____________________________________ ENCRYPTION ({@link #ENCRYPTION_MASK})
+   *     |        _____________________________ SECURE MESSAGE INFORMATION (no mask, but look at {@link #SECURE_MESSAGE_BIT})
+   *     |       |     ________________________ GROUPS (no mask, but look at {@link #GROUP_UPDATE_BIT})
+   *     |       |    |       _________________ KEY_EXCHANGE ({@link #KEY_EXCHANGE_MASK})
+   *     |       |    |      |       _________  MESSAGE_ATTRIBUTES ({@link #MESSAGE_ATTRIBUTE_MASK})
+   *     |       |    |      |      |     ____  BASE_TYPE ({@link #BASE_TYPE_MASK})
+   *  ___|___   _|   _|   ___|__    |  __|_
+   * |       | |  | |  | |       | | ||    |
+   * 0000 0000 0000 0000 0000 0000 0000 0000
+   * 
+ */ public static class Types { + protected static final long TOTAL_MASK = 0xFFFFFFFF; // Base Types @@ -63,8 +90,10 @@ public interface MmsSmsColumns { OUTGOING_AUDIO_CALL_TYPE, OUTGOING_VIDEO_CALL_TYPE}; // Message attributes - protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0; - protected static final long MESSAGE_FORCE_SMS_BIT = 0x40; + protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0; + protected static final long MESSAGE_RATE_LIMITED_BIT = 0x80; + protected static final long MESSAGE_FORCE_SMS_BIT = 0x40; + // Note: Might be wise to reserve 0x20 -- it would let us expand BASE_MASK by a bit if needed // Key Exchange Information protected static final long KEY_EXCHANGE_MASK = 0xFF00; @@ -210,6 +239,10 @@ public interface MmsSmsColumns { return (type & KEY_EXCHANGE_IDENTITY_UPDATE_BIT) != 0; } + public static boolean isRateLimited(long type) { + return (type & MESSAGE_RATE_LIMITED_BIT) != 0; + } + public static boolean isCallLog(long type) { return isIncomingAudioCall(type) || isIncomingVideoCall(type) || diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 0a478335fa..f1ba08d4d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -47,7 +47,6 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob; import org.thoughtcrime.securesms.mms.IncomingMediaMessage; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; -import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; @@ -321,6 +320,27 @@ public class SmsDatabase extends MessageDatabase { updateTypeBitmask(id, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT); } + @Override + public void markAsRateLimited(long id) { + updateTypeBitmask(id, 0, Types.MESSAGE_RATE_LIMITED_BIT); + } + + @Override + public void clearRateLimitStatus(@NonNull Collection ids) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (long id : ids) { + updateTypeBitmask(id, Types.MESSAGE_RATE_LIMITED_BIT, 0); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + @Override public void markAsDecryptFailed(long id) { updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT); @@ -887,6 +907,22 @@ public class SmsDatabase extends MessageDatabase { return new Pair<>(messageId, threadId); } + @Override + public Set getAllRateLimitedMessageIds() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = "(" + TYPE + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0"; + + Set ids = new HashSet<>(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) { + while (cursor.moveToNext()) { + ids.add(CursorUtil.requireLong(cursor, ID)); + } + } + + return ids; + } + @Override public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) { String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 5cd33f5741..cb31010135 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -423,6 +423,10 @@ public abstract class MessageRecord extends DisplayRecord { return SmsDatabase.Types.isContentBundleKeyExchange(type); } + public boolean isRateLimited() { + return SmsDatabase.Types.isRateLimited(type); + } + public boolean isIdentityUpdate() { return SmsDatabase.Types.isIdentityUpdate(type); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java index 54f1bcb4ec..1da84b91ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java @@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.jobs.SubmitRateLimitPushChallengeJob; import org.thoughtcrime.securesms.registration.PushChallengeRequest; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -30,10 +31,14 @@ public class FcmReceiveService extends FirebaseMessagingService { remoteMessage.getPriority(), remoteMessage.getOriginalPriority())); - String challenge = remoteMessage.getData().get("challenge"); - if (challenge != null) { - handlePushChallenge(challenge); - } else { + String registrationChallenge = remoteMessage.getData().get("challenge"); + String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge"); + + if (registrationChallenge != null) { + handleRegistrationPushChallenge(registrationChallenge); + } else if (rateLimitChallenge != null) { + handleRateLimitPushChallenge(rateLimitChallenge); + }else { handleReceivedNotification(ApplicationDependencies.getApplication()); } } @@ -75,9 +80,13 @@ public class FcmReceiveService extends FirebaseMessagingService { } } - private static void handlePushChallenge(@NonNull String challenge) { - Log.d(TAG, String.format("Got a push challenge \"%s\"", challenge)); - + private static void handleRegistrationPushChallenge(@NonNull String challenge) { + Log.d(TAG, "Got a registration push challenge."); PushChallengeRequest.postChallengeResponse(challenge); } + + private static void handleRateLimitPushChallenge(@NonNull String challenge) { + Log.d(TAG, "Got a rate limit push challenge."); + ApplicationDependencies.getJobManager().add(new SubmitRateLimitPushChallengeJob(challenge)); + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java index c406ec09d6..51cd4745c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -176,6 +176,23 @@ class JobController { .forEach(this::cancelJob); } + @WorkerThread + synchronized void update(@NonNull JobUpdater updater) { + List allJobs = jobStorage.getAllJobSpecs(); + List updatedJobs = new LinkedList<>(); + + for (JobSpec job : allJobs) { + JobSpec updated = updater.update(job, dataSerializer); + if (updated != job) { + updatedJobs.add(updated); + } + } + + jobStorage.updateJobs(updatedJobs); + + notifyAll(); + } + @WorkerThread synchronized void onRetry(@NonNull Job job, long backoffInterval) { if (backoffInterval <= 0) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index 5ebb22a435..ee507b908b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -223,6 +223,15 @@ public class JobManager implements ConstraintObserver.Notifier { runOnExecutor(() -> jobController.cancelAllInQueue(queue)); } + /** + * Perform an arbitrary update on enqueued jobs. Will not apply to jobs that are already running. + * You shouldn't use this if you can help it. You give yourself an opportunity to really screw + * things up. + */ + public void update(@NonNull JobUpdater updater) { + runOnExecutor(() -> jobController.update(updater)); + } + /** * Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning * you must take great care where you call this. It could take a very long time to complete! diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java new file mode 100644 index 0000000000..6a4ca51b1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.jobmanager; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec; + +public interface JobUpdater { + /** + * Called for each enqueued job, giving you an opportunity to update each one. + * + * @param jobSpec An object representing data about an enqueued job. + * @param serializer An object that can be used to serialize/deserialize data if necessary for + * your update. + * + * @return The updated JobSpec you want persisted. If you do not wish to make an update, return + * the literal same JobSpec instance you were provided. + */ + @NonNull JobSpec update(@NonNull JobSpec jobSpec, @NonNull Data.Serializer serializer); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java index 7c6f96541b..422e7b9c45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java @@ -49,6 +49,10 @@ public final class JobSpec { this.memoryOnly = memoryOnly; } + public @NonNull JobSpec withNextRunAttemptTime(long updated) { + return new JobSpec(id, factoryKey, queueKey, createTime, updated, runAttempt, maxAttempts, lifespan, serializedData, serializedInputData, isRunning, memoryOnly); + } + public @NonNull String getId() { return id; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 53c8e2f5a9..a02034cbe8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -144,7 +144,8 @@ public final class JobManagerFactories { put(StickerDownloadJob.KEY, new StickerDownloadJob.Factory()); put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory()); put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory()); - put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); + put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); + put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory()); put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index a5dde5bc7d..93da38e547 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -11,7 +11,6 @@ import com.annimon.stream.Stream; import com.google.protobuf.ByteString; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -55,6 +54,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2; @@ -151,7 +151,7 @@ public final class PushGroupSendJob extends PushSendJob { @Override public void onPushSend() - throws IOException, MmsException, NoSuchMessageException, RetryLaterException + throws IOException, MmsException, NoSuchMessageException, RetryLaterException { MessageDatabase database = DatabaseFactory.getMmsDatabase(context); OutgoingMediaMessage message = database.getOutgoingMessage(messageId); @@ -197,6 +197,7 @@ public final class PushGroupSendJob extends PushSendJob { List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(findId(result.getAddress(), idByE164, idByUuid))).toList(); List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(findId(result.getAddress(), idByE164, idByUuid), result.getIdentityFailure().getIdentityKey())).toList(); + ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null); List successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(findId(result.getAddress(), idByE164, idByUuid), result.getSuccess().isUnidentified())).toList(); Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet()); @@ -229,6 +230,10 @@ public final class PushGroupSendJob extends PushSendJob { DatabaseFactory.getGroupReceiptDatabase(context).setUnidentified(successUnidentifiedStatus, messageId); + if (proofRequired != null) { + handleProofRequiredException(proofRequired, groupRecipient, threadId, messageId, true); + } + if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) { database.markAsSent(messageId, true); @@ -391,6 +396,10 @@ public final class PushGroupSendJob extends PushSendJob { return RecipientUtil.getEligibleForSending(members); } + public static long getMessageId(@NonNull Data data) { + return data.getLong(KEY_MESSAGE_ID); + } + public static class Factory implements Job.Factory { @Override public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java index 8a753d690d..b7c64172d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -8,7 +8,6 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -41,6 +40,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Pr import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; @@ -105,7 +105,7 @@ public class PushMediaSendJob extends PushSendJob { @Override public void onPushSend() - throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException + throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException { ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager(); MessageDatabase database = DatabaseFactory.getMmsDatabase(context); @@ -172,6 +172,8 @@ public class PushMediaSendJob extends PushSendJob { database.addMismatchedIdentity(messageId, recipientId, uie.getIdentityKey()); database.markAsSentFailed(messageId); RetrieveProfileJob.enqueue(recipientId); + } catch (ProofRequiredException e) { + handleProofRequiredException(e, DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId), threadId, messageId, true); } } @@ -235,6 +237,10 @@ public class PushMediaSendJob extends PushSendJob { } } + public static long getMessageId(@NonNull Data data) { + return data.getLong(KEY_MESSAGE_ID); + } + public static final class Factory implements Job.Factory { @Override public @NonNull PushMediaSendJob create(@NonNull Parameters parameters, @NonNull Data data) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 1bc64bb1de..ec554fd625 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -11,6 +11,9 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.metadata.certificate.InvalidCertificateException; import org.signal.libsignal.metadata.certificate.SenderCertificate; @@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.registration.PushChallengeRequest; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.BitmapDecodingException; @@ -49,6 +53,7 @@ import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; @@ -60,7 +65,10 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.internal.push.ProofRequiredResponse; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -72,12 +80,14 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; public abstract class PushSendJob extends SendJob { private static final String TAG = Log.tag(PushSendJob.class); private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1); + private static final long PUSH_CHALLENGE_TIMEOUT = TimeUnit.SECONDS.toMillis(10); protected PushSendJob(Job.Parameters parameters) { super(parameters); @@ -100,6 +110,11 @@ public abstract class PushSendJob extends SendJob { } onPushSend(); + + if (SignalStore.rateLimit().needsRecaptcha()) { + Log.i(TAG, "Successfully sent message. Assuming reCAPTCHA no longer needed."); + SignalStore.rateLimit().onProofAccepted(); + } } @Override @@ -124,16 +139,28 @@ public abstract class PushSendJob extends SendJob { return false; } - return exception instanceof IOException || - exception instanceof RetryLaterException; + return exception instanceof IOException || + exception instanceof RetryLaterException || + exception instanceof ProofRequiredException; } @Override public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) { - if (exception instanceof NonSuccessfulResponseCodeException) { + if (exception instanceof ProofRequiredException) { + long backoff = ((ProofRequiredException) exception).getRetryAfterSeconds(); + warn(TAG, "[Proof Required] Retry-After is " + backoff + " seconds."); + if (backoff >= 0) { + return TimeUnit.SECONDS.toMillis(backoff); + } + } else if (exception instanceof NonSuccessfulResponseCodeException) { if (((NonSuccessfulResponseCodeException) exception).is5xx()) { return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getServerErrorMaxBackoff()); } + } else if (exception instanceof RetryLaterException) { + long backoff = ((RetryLaterException) exception).getBackoff(); + if (backoff >= 0) { + return backoff; + } } return super.getNextRunAttemptBackoff(pastAttemptCount, exception); @@ -422,6 +449,81 @@ public abstract class PushSendJob extends SendJob { return SignalServiceSyncMessage.forSentTranscript(transcript); } + protected void handleProofRequiredException(@NonNull ProofRequiredException proofRequired, @Nullable Recipient recipient, long threadId, long messageId, boolean isMms) + throws ProofRequiredException, RetryLaterException + { + try { + if (proofRequired.getOptions().contains(ProofRequiredException.Option.PUSH_CHALLENGE)) { + ApplicationDependencies.getSignalServiceAccountManager().requestRateLimitPushChallenge(); + log(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to " + PUSH_CHALLENGE_TIMEOUT + " ms."); + + boolean success = new PushChallengeRequest(PUSH_CHALLENGE_TIMEOUT).blockUntilSuccess(); + + if (success) { + log(TAG, "Successfully responded to a push challenge. Retrying message send."); + throw new RetryLaterException(1); + } else { + warn(TAG, "Failed to respond to the push challenge in time. Falling back."); + } + } + } catch (NonSuccessfulResponseCodeException e) { + warn(TAG, "[Proof Required] Could not request a push challenge (" + e.getCode() + "). Falling back.", e); + } catch (IOException e) { + warn(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later."); + throw new RetryLaterException(e); + } + + warn(TAG, "[Proof Required] Marking message as rate-limited. (id: " + messageId + ", mms: " + isMms + ", thread: " + threadId + ")"); + if (isMms) { + DatabaseFactory.getMmsDatabase(context).markAsRateLimited(messageId); + } else { + DatabaseFactory.getSmsDatabase(context).markAsRateLimited(messageId); + } + + if (proofRequired.getOptions().contains(ProofRequiredException.Option.RECAPTCHA)) { + log(TAG, "[Proof Required] ReCAPTCHA required."); + SignalStore.rateLimit().markNeedsRecaptcha(proofRequired.getToken()); + + if (recipient != null) { + ApplicationDependencies.getMessageNotifier().notifyProofRequired(context, recipient, threadId); + } else { + warn(TAG, "[Proof Required] No recipient! Couldn't notify."); + } + } + + throw proofRequired; + } protected abstract void onPushSend() throws Exception; + + public static class PushChallengeRequest { + private final long timeout; + private final CountDownLatch latch; + private final EventBus eventBus; + + private PushChallengeRequest(long timeout) { + this.timeout = timeout; + this.latch = new CountDownLatch(1); + this.eventBus = EventBus.getDefault(); + } + + public boolean blockUntilSuccess() { + eventBus.register(this); + + try { + return latch.await(timeout, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Log.w(TAG, "[Proof Required] Interrupted?", e); + return false; + } finally { + eventBus.unregister(this); + } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + public void onSuccessReceived(SubmitRateLimitPushChallengeJob.SuccessEvent event) { + Log.i(TAG, "[Proof Required] Received a successful result!"); + latch.countDown(); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java index 9f995b722e..d17f6d5046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.jobs; import androidx.annotation.NonNull; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessageDatabase; @@ -14,7 +13,6 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; @@ -31,12 +29,11 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import java.io.IOException; -import java.util.concurrent.TimeUnit; public class PushTextSendJob extends PushSendJob { @@ -73,7 +70,7 @@ public class PushTextSendJob extends PushSendJob { } @Override - public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException { + public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException { ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager(); MessageDatabase database = DatabaseFactory.getSmsDatabase(context); SmsMessageRecord record = database.getSmsMessage(messageId); @@ -133,6 +130,8 @@ public class PushTextSendJob extends PushSendJob { database.markAsSentFailed(record.getId()); database.markAsPush(record.getId()); RetrieveProfileJob.enqueue(recipientId); + } catch (ProofRequiredException e) { + handleProofRequiredException(e, record.getRecipient(), record.getThreadId(), messageId, false); } } @@ -187,6 +186,10 @@ public class PushTextSendJob extends PushSendJob { } } + public static long getMessageId(@NonNull Data data) { + return data.getLong(KEY_MESSAGE_ID); + } + public static class Factory implements Job.Factory { @Override public @NonNull PushTextSendJob create(@NonNull Parameters parameters, @NonNull Data data) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java index 45ff1d9cb7..ebc5e6f9fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java @@ -47,6 +47,7 @@ public class RotateProfileKeyJob extends BaseJob { ApplicationDependencies.getJobManager().add(new ProfileUploadJob()); ApplicationDependencies.getJobManager().add(new RefreshAttributesJob()); + ApplicationDependencies.getJobManager().add(new MultiDeviceProfileKeyUpdateJob()); updateProfileKeyOnAllV2Groups(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java new file mode 100644 index 0000000000..5f224f017b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.ratelimit.RateLimitUtil; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.concurrent.TimeUnit; + +/** + * Send a push challenge token to the service as a way of proving that your device has FCM. + */ +public final class SubmitRateLimitPushChallengeJob extends BaseJob { + + public static final String KEY = "SubmitRateLimitPushChallengeJob"; + + private static final String KEY_CHALLENGE = "challenge"; + + private final String challenge; + + public SubmitRateLimitPushChallengeJob(@NonNull String challenge) { + this(new Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.HOURS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + challenge); + } + + private SubmitRateLimitPushChallengeJob(@NonNull Parameters parameters, @NonNull String challenge) { + super(parameters); + this.challenge = challenge; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_CHALLENGE, challenge).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitPushChallenge(challenge); + SignalStore.rateLimit().onProofAccepted(); + EventBus.getDefault().post(new SuccessEvent()); + RateLimitUtil.retryAllRateLimitedMessages(context); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class SuccessEvent { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull SubmitRateLimitPushChallengeJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new SubmitRateLimitPushChallengeJob(parameters, data.getString(KEY_CHALLENGE)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java new file mode 100644 index 0000000000..dd208f5750 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.EventBus; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent; + +import java.util.Collections; +import java.util.List; + +public final class RateLimitValues extends SignalStoreValues { + + private static final String TAG = Log.tag(RateLimitValues.class); + + private static final String KEY_NEEDS_RECAPTCHA = "ratelimit.needs_recaptcha"; + private static final String KEY_CHALLENGE = "ratelimit.token"; + + RateLimitValues(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + @Override + @NonNull List getKeysToIncludeInBackup() { + return Collections.emptyList(); + } + + /** + * @param challenge The token associated with the rate limit response. + */ + public void markNeedsRecaptcha(@NonNull String challenge) { + Log.i(TAG, "markNeedsRecaptcha()"); + putBoolean(KEY_NEEDS_RECAPTCHA, true); + putString(KEY_CHALLENGE, challenge); + EventBus.getDefault().post(new RecaptchaRequiredEvent()); + } + + public void onProofAccepted() { + Log.i(TAG, "onProofAccepted()", new Throwable()); + putBoolean(KEY_NEEDS_RECAPTCHA, false); + remove(KEY_CHALLENGE); + } + + public boolean needsRecaptcha() { + return getBoolean(KEY_NEEDS_RECAPTCHA, false); + } + + public @NonNull String getChallenge() { + return getString(KEY_CHALLENGE, ""); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 5bbffadf61..9aa5f130e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -35,6 +35,7 @@ public final class SignalStore { private final WallpaperValues wallpaperValues; private final PaymentsValues paymentsValues; private final ProxyValues proxyValues; + private final RateLimitValues rateLimitValues; private SignalStore() { this.store = new KeyValueStore(ApplicationDependencies.getApplication()); @@ -55,6 +56,7 @@ public final class SignalStore { this.wallpaperValues = new WallpaperValues(store); this.paymentsValues = new PaymentsValues(store); this.proxyValues = new ProxyValues(store); + this.rateLimitValues = new RateLimitValues(store); } public static void onFirstEverAppLaunch() { @@ -75,6 +77,7 @@ public final class SignalStore { wallpaper().onFirstEverAppLaunch(); paymentsValues().onFirstEverAppLaunch(); proxy().onFirstEverAppLaunch(); + rateLimit().onFirstEverAppLaunch(); } public static List getKeysToIncludeInBackup() { @@ -96,6 +99,7 @@ public final class SignalStore { keys.addAll(wallpaper().getKeysToIncludeInBackup()); keys.addAll(paymentsValues().getKeysToIncludeInBackup()); keys.addAll(proxy().getKeysToIncludeInBackup()); + keys.addAll(rateLimit().getKeysToIncludeInBackup()); return keys; } @@ -176,6 +180,10 @@ public final class SignalStore { return INSTANCE.proxyValues; } + public static @NonNull RateLimitValues rateLimit() { + return INSTANCE.rateLimitValues; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java index f7f2e1be1e..cf69756e82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java @@ -67,4 +67,8 @@ abstract class SignalStoreValues { void putString(@NonNull String key, String value) { store.beginWrite().putString(key, value).apply(); } + + void remove(@NonNull String key) { + store.beginWrite().remove(key); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 358dc9e97f..7845a52c81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; +import org.thoughtcrime.securesms.ratelimit.RateLimitUtil; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.ringrtc.RemotePeer; @@ -944,6 +945,11 @@ public final class MessageContentProcessor { ApplicationDependencies.getMessageNotifier().updateNotification(context); } + if (SignalStore.rateLimit().needsRecaptcha()) { + Log.i(TAG, "Got a sent transcript while in reCAPTCHA mode. Assuming we're good to message again."); + RateLimitUtil.retryAllRateLimitedMessages(context); + } + ApplicationDependencies.getMessageNotifier().setLastDesktopActivityTimestamp(message.getTimestamp()); } catch (MmsException e) { throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index bee9792729..648854a63d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -23,6 +23,7 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.database.Cursor; +import android.graphics.BitmapFactory; import android.media.AudioAttributes; import android.media.AudioManager; import android.media.Ringtone; @@ -131,7 +132,7 @@ public class DefaultMessageNotifier implements MessageNotifier { } @Override - public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { + public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) { if (visibleThread == threadId) { sendInThreadNotification(context, recipient); } else { @@ -145,6 +146,15 @@ public class DefaultMessageNotifier implements MessageNotifier { } } + @Override + public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) { + if (visibleThread == threadId) { + sendInThreadNotification(context, recipient); + } else { + Log.w(TAG, "[Proof Required] Not notifying on old notifier."); + } + } + @Override public void cancelDelayedNotifications() { executor.cancel(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java index 138c995804..40ad22c287 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -16,7 +16,8 @@ public interface MessageNotifier { long getVisibleThread(); void clearVisibleThread(); void setLastDesktopActivityTimestamp(long timestamp); - void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId); + void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId); + void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId); void cancelDelayedNotifications(); void updateNotification(@NonNull Context context); void updateNotification(@NonNull Context context, long threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index a77381345d..406fd644c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -53,10 +53,15 @@ public class OptimizedMessageNotifier implements MessageNotifier { } @Override - public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) { + public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) { getNotifier().notifyMessageDeliveryFailed(context, recipient, threadId); } + @Override + public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) { + getNotifier().notifyProofRequired(context, recipient, threadId); + } + @Override public void cancelDelayedNotifications() { getNotifier().cancelDelayedNotifications(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt index 606e9ec14e..4059db6022 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt @@ -76,6 +76,10 @@ class MessageNotifierV2(context: Application) : MessageNotifier { NotificationFactory.notifyMessageDeliveryFailed(context, recipient, threadId, visibleThread) } + override fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long) { + NotificationFactory.notifyProofRequired(context, recipient, threadId, visibleThread) + } + override fun cancelDelayedNotifications() { executor.cancel() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt index 97b0948466..87a9b91775 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt @@ -315,6 +315,33 @@ object NotificationFactory { NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build()) } + fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long, visibleThread: Long) { + if (threadId == visibleThread) { + notifyInThread(context, recipient, 0) + return + } + + val intent: Intent = ConversationIntents.createBuilder(context, recipient.id, threadId) + .build() + .makeUniqueToPreventMerging() + + val builder: NotificationBuilder = NotificationBuilder.create(context) + + builder.apply { + setSmallIcon(R.drawable.ic_notification) + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_info_outline)) + setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_paused)) + setContentText(context.getString(R.string.MessageNotifier_verify_to_continue_messaging_on_signal)) + setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)) + setOnlyAlertOnce(true) + setAutoCancel(true) + setAlarms(recipient) + setChannelId(NotificationChannels.FAILURES) + } + + NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build()) + } + private fun NotificationManagerCompat.safelyNotify(context: Context, threadRecipient: Recipient?, notificationId: Int, notification: Notification) { try { notify(notificationId, notification) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java new file mode 100644 index 0000000000..479a4e860a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.ratelimit; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobs.PushGroupSendJob; +import org.thoughtcrime.securesms.jobs.PushMediaSendJob; +import org.thoughtcrime.securesms.jobs.PushTextSendJob; + +import java.util.Set; + +public final class RateLimitUtil { + + private static final String TAG = Log.tag(RateLimitUtil.class); + + private RateLimitUtil() {} + + /** + * Forces a retry of all rate limited messages by editing jobs that are in the queue. + */ + @WorkerThread + public static void retryAllRateLimitedMessages(@NonNull Context context) { + Set sms = DatabaseFactory.getSmsDatabase(context).getAllRateLimitedMessageIds(); + Set mms = DatabaseFactory.getMmsDatabase(context).getAllRateLimitedMessageIds(); + + if (sms.isEmpty() && mms.isEmpty()) { + return; + } + + Log.i(TAG, "Retrying " + sms.size() + " sms records and " + mms.size() + " mms records."); + + DatabaseFactory.getSmsDatabase(context).clearRateLimitStatus(sms); + DatabaseFactory.getMmsDatabase(context).clearRateLimitStatus(mms); + + ApplicationDependencies.getJobManager().update((job, serializer) -> { + Data data = serializer.deserialize(job.getSerializedData()); + + if (job.getFactoryKey().equals(PushTextSendJob.KEY) && sms.contains(PushTextSendJob.getMessageId(data))) { + return job.withNextRunAttemptTime(System.currentTimeMillis()); + } else if (job.getFactoryKey().equals(PushMediaSendJob.KEY) && mms.contains(PushMediaSendJob.getMessageId(data))) { + return job.withNextRunAttemptTime(System.currentTimeMillis()); + } else if (job.getFactoryKey().equals(PushGroupSendJob.KEY) && mms.contains(PushGroupSendJob.getMessageId(data))) { + return job.withNextRunAttemptTime(System.currentTimeMillis()); + } else { + return job; + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java new file mode 100644 index 0000000000..5ad9351363 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.ratelimit; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.os.Bundle; +import android.view.MenuItem; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; + +/** + * Asks the user to solve a reCAPTCHA. If successful, triggers resends of all relevant message jobs. + */ +public class RecaptchaProofActivity extends PassphraseRequiredActivity { + private static final String TAG = Log.tag(RecaptchaProofActivity.class); + + private static final String RECAPTCHA_SCHEME = "signalcaptcha://"; + + 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); + } + + @Override + @SuppressLint("SetJavaScriptEnabled") + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + + setContentView(R.layout.recaptcha_activity); + + requireSupportActionBar().setDisplayHomeAsUpEnabled(true); + requireSupportActionBar().setTitle(R.string.RecaptchaProofActivity_complete_verification); + + WebView webView = findViewById(R.id.recaptcha_webview); + webView.getSettings().setJavaScriptEnabled(true); + webView.clearCache(true); + webView.setBackgroundColor(Color.TRANSPARENT); + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url != null && url.startsWith(RECAPTCHA_SCHEME)) { + handleToken(url.substring(RECAPTCHA_SCHEME.length())); + return true; + } + return false; + } + }); + + webView.loadUrl(BuildConfig.RECAPTCHA_PROOF_URL); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void handleToken(@NonNull String token) { + SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(this, 1000, 500); + SimpleTask.run(() -> { + String challenge = SignalStore.rateLimit().getChallenge(); + if (Util.isEmpty(challenge)) { + Log.w(TAG, "No challenge available?"); + return new TokenResult(true, false); + } + + try { + for (int i = 0; i < 3; i++) { + try { + ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitRecaptchaChallenge(challenge, token); + RateLimitUtil.retryAllRateLimitedMessages(this); + Log.i(TAG, "Successfully completed reCAPTCHA."); + return new TokenResult(true, true); + } catch (PushNetworkException e) { + Log.w(TAG, "Network error during submission. Retrying.", e); + } + } + } catch (IOException e) { + Log.w(TAG, "Terminal failure during submission. Will clear state. May get a 428 later.", e); + return new TokenResult(true, false); + } + + return new TokenResult(false, false); + }, result -> { + dialog.dismiss(); + + if (result.clearState) { + Log.i(TAG, "Considering the response sufficient to clear the slate."); + SignalStore.rateLimit().onProofAccepted(); + } + + if (!result.success) { + Log.w(TAG, "Response was not a true success."); + Toast.makeText(this, R.string.RecaptchaProofActivity_failed_to_submit, Toast.LENGTH_LONG).show(); + } + + finish(); + }); + } + + private static final class TokenResult { + final boolean clearState; + final boolean success; + + private TokenResult(boolean clearState, boolean success) { + this.clearState = clearState; + this.success = success; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java new file mode 100644 index 0000000000..13ede18100 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.ratelimit; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; + +/** + * A bottom sheet to be shown when we need to prompt the user to fill out a reCAPTCHA. + */ +public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFragment { + + private static final String TAG = Log.tag(RecaptchaProofBottomSheetFragment.class); + + public static void show(@NonNull FragmentManager manager) { + new RecaptchaProofBottomSheetFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, R.style.Signal_DayNight_BottomSheet_Rounded); + super.onCreate(savedInstanceState); + } + + @Override + 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 -> { + dismissAllowingStateLoss(); + startActivity(RecaptchaProofActivity.getIntent(requireContext())); + }); + + return view; + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + if (manager.findFragmentByTag(tag) == null) { + BottomSheetUtil.show(manager, tag, this); + } else { + Log.i(TAG, "Ignoring repeat show."); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java new file mode 100644 index 0000000000..ae71fc1d08 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java @@ -0,0 +1,4 @@ +package org.thoughtcrime.securesms.ratelimit; + +public final class RecaptchaRequiredEvent { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java index 4a99b2a146..00ca6a5830 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java @@ -91,7 +91,7 @@ public final class PushChallengeRequest { eventBus.register(this); try { - accountManager.requestPushChallenge(fcmToken, e164number); + accountManager.requestRegistrationPushChallenge(fcmToken, e164number); latch.await(timeoutMs, TimeUnit.MILLISECONDS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java index 626bbafcdf..fe83f525ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java +++ b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java @@ -1,9 +1,30 @@ package org.thoughtcrime.securesms.transport; public class RetryLaterException extends Exception { - public RetryLaterException() {} + + private final long backoff; + + public RetryLaterException() { + this(null, -1); + } + + public RetryLaterException(long backoff) { + this(null, backoff); + } public RetryLaterException(Exception e) { + this(e, -1); + } + + public RetryLaterException(Exception e, long backoff) { super(e); + this.backoff = backoff; + } + + /** + * @return The amount of time to wait before retrying again, or -1 if none is specified. + */ + public long getBackoff() { + return backoff; } } 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 66b1617125..2513281682 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.util; +import android.app.Application; import android.os.Build; import android.text.TextUtils; diff --git a/app/src/main/res/layout/recaptcha_activity.xml b/app/src/main/res/layout/recaptcha_activity.xml new file mode 100644 index 0000000000..64a860c6ed --- /dev/null +++ b/app/src/main/res/layout/recaptcha_activity.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml b/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml new file mode 100644 index 0000000000..3aaa5b06bc --- /dev/null +++ b/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml @@ -0,0 +1,68 @@ + + + + + + + + + +