Handle 428 rate limiting.
This commit is contained in:
parent
02d060ca0a
commit
31e1c6f7aa
60 changed files with 1235 additions and 57 deletions
|
@ -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\""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -573,6 +573,10 @@
|
|||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity android:name=".ratelimit.RecaptchaProofActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" />
|
||||
|
||||
<activity android:name=".wallpaper.crop.WallpaperImageSelectionActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.FullScreenMedia" />
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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> recipientId;
|
||||
private final LiveData<ChatWallpaper> wallpaper;
|
||||
private final SingleLiveEvent<Event> 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<Event> 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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp);
|
||||
public abstract Set<Long> 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<Long> ids);
|
||||
public abstract void markAsDecryptFailed(long id);
|
||||
public abstract void markAsDecryptDuplicate(long id);
|
||||
public abstract void markAsNoSession(long id);
|
||||
|
|
|
@ -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<Long> 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<Long> getAllRateLimitedMessageIds() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String where = "(" + MESSAGE_BOX + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0";
|
||||
|
||||
Set<Long> 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<Long> threadIds) {
|
||||
Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")");
|
||||
|
|
|
@ -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}.
|
||||
*
|
||||
* <pre>
|
||||
* _____________________________________ 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
|
||||
* </pre>
|
||||
*/
|
||||
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) ||
|
||||
|
|
|
@ -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<Long> 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<Long> getAllRateLimitedMessageIds() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String where = "(" + TYPE + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0";
|
||||
|
||||
Set<Long> 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<MessageRecord> getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
|
||||
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -176,6 +176,23 @@ class JobController {
|
|||
.forEach(this::cancelJob);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
synchronized void update(@NonNull JobUpdater updater) {
|
||||
List<JobSpec> allJobs = jobStorage.getAllJobSpecs();
|
||||
List<JobSpec> 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) {
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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<NetworkFailure> networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(findId(result.getAddress(), idByE164, idByUuid))).toList();
|
||||
List<IdentityKeyMismatch> 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<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
|
||||
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(findId(result.getAddress(), idByE164, idByUuid), result.getSuccess().isUnidentified())).toList();
|
||||
Set<RecipientId> 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<PushGroupSendJob> {
|
||||
@Override
|
||||
public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) {
|
||||
|
|
|
@ -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<PushMediaSendJob> {
|
||||
@Override
|
||||
public @NonNull PushMediaSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<PushTextSendJob> {
|
||||
@Override
|
||||
public @NonNull PushTextSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<SubmitRateLimitPushChallengeJob> {
|
||||
@Override
|
||||
public @NonNull SubmitRateLimitPushChallengeJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new SubmitRateLimitPushChallengeJob(parameters, data.getString(KEY_CHALLENGE));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String> 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, "");
|
||||
}
|
||||
}
|
|
@ -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<String> 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());
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<Long> sms = DatabaseFactory.getSmsDatabase(context).getAllRateLimitedMessageIds();
|
||||
Set<Long> 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package org.thoughtcrime.securesms.ratelimit;
|
||||
|
||||
public final class RecaptchaRequiredEvent {
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import android.app.Application;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
|
|
13
app/src/main/res/layout/recaptcha_activity.xml
Normal file
13
app/src/main/res/layout/recaptcha_activity.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/recaptcha_webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</ScrollView>
|
68
app/src/main/res/layout/recaptcha_required_bottom_sheet.xml
Normal file
68
app/src/main/res/layout/recaptcha_required_bottom_sheet.xml
Normal file
|
@ -0,0 +1,68 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:theme="@style/Theme.Signal.RoundedBottomSheet.Light">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recaptcha_sheet_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/RecaptchaRequiredBottomSheetFragment_verify_to_continue_messaging"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Title2.Bold"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recaptcha_sheet_paragraph_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/RecaptchaRequiredBottomSheetFragment_to_help_prevent_spam_on_signal"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/recaptcha_sheet_paragraph_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:text="@string/RecaptchaRequiredBottomSheetFragment_after_verifying_you_can_continue_messaging"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_paragraph_1" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/recaptcha_sheet_ok_button"
|
||||
style="@style/Button.Primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="38dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:text="@android:string/ok"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/recaptcha_sheet_paragraph_2"
|
||||
app:layout_constraintVertical_bias="1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -202,6 +202,7 @@
|
|||
<string name="ConversationItem_error_network_not_delivered">Send failed</string>
|
||||
<string name="ConversationItem_received_key_exchange_message_tap_to_process">Received key exchange message, tap to process.</string>
|
||||
<string name="ConversationItem_group_action_left">%1$s has left the group.</string>
|
||||
<string name="ConversationItem_send_paused">Send paused</string>
|
||||
<string name="ConversationItem_click_to_approve_unencrypted">Send failed, tap for unsecured fallback</string>
|
||||
<string name="ConversationItem_click_to_approve_unencrypted_sms_dialog_title">Fallback to unencrypted SMS?</string>
|
||||
<string name="ConversationItem_click_to_approve_unencrypted_mms_dialog_title">Fallback to unencrypted MMS?</string>
|
||||
|
@ -1354,6 +1355,11 @@
|
|||
<!-- ReactionsRecipientAdapter -->
|
||||
<string name="ReactionsRecipientAdapter_you">You</string>
|
||||
|
||||
<!-- RecaptchaRequiredBottomSheetFragment -->
|
||||
<string name="RecaptchaRequiredBottomSheetFragment_verify_to_continue_messaging">Verify to continue messaging</string>
|
||||
<string name="RecaptchaRequiredBottomSheetFragment_to_help_prevent_spam_on_signal">To help prevent spam on Signal, please complete verification.</string>
|
||||
<string name="RecaptchaRequiredBottomSheetFragment_after_verifying_you_can_continue_messaging">After verifying, you can continue messaging. Any paused messages will automatically be sent.</string>
|
||||
|
||||
<!-- RecipientPreferencesActivity -->
|
||||
<string name="RecipientPreferenceActivity_block">Block</string>
|
||||
<string name="RecipientPreferenceActivity_unblock">Unblock</string>
|
||||
|
@ -1422,6 +1428,9 @@
|
|||
<string name="ProxyBottomSheetFragment_use_proxy">Use proxy</string>
|
||||
<string name="ProxyBottomSheetFragment_successfully_connected_to_proxy">Successfully connected to proxy.</string>
|
||||
|
||||
<!-- RecaptchaProofActivity -->
|
||||
<string name="RecaptchaProofActivity_failed_to_submit">Failed to submit</string>
|
||||
<string name="RecaptchaProofActivity_complete_verification">Complete verification</string>
|
||||
|
||||
<!-- RegistrationActivity -->
|
||||
<string name="RegistrationActivity_select_your_country">Select your country</string>
|
||||
|
@ -1694,6 +1703,8 @@
|
|||
<string name="MessageNotifier_message_delivery_failed">Message delivery failed.</string>
|
||||
<string name="MessageNotifier_failed_to_deliver_message">Failed to deliver message.</string>
|
||||
<string name="MessageNotifier_error_delivering_message">Error delivering message.</string>
|
||||
<string name="MessageNotifier_message_delivery_paused">Message delivery paused.</string>
|
||||
<string name="MessageNotifier_verify_to_continue_messaging_on_signal">Verify to continue messaging on Signal.</string>
|
||||
<string name="MessageNotifier_mark_all_as_read">Mark all as read</string>
|
||||
<string name="MessageNotifier_mark_read">Mark read</string>
|
||||
<string name="MessageNotifier_turn_off_these_notifications">Turn off these notifications</string>
|
||||
|
|
|
@ -56,14 +56,14 @@ public final class PushChallengeRequestTest {
|
|||
doAnswer(invocation -> {
|
||||
AsyncTask.execute(() -> PushChallengeRequest.postChallengeResponse("CHALLENGE"));
|
||||
return null;
|
||||
}).when(signal).requestPushChallenge("token", "+123456");
|
||||
}).when(signal).requestRegistrationPushChallenge("token", "+123456");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
Optional<String> challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L);
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
assertThat(duration, lessThan(500L));
|
||||
verify(signal).requestPushChallenge("token", "+123456");
|
||||
verify(signal).requestRegistrationPushChallenge("token", "+123456");
|
||||
verifyNoMoreInteractions(signal);
|
||||
|
||||
assertTrue(challenge.isPresent());
|
||||
|
@ -95,7 +95,7 @@ public final class PushChallengeRequestTest {
|
|||
public void getPushChallengeBlocking_returns_absent_if_any_IOException_is_thrown() throws IOException {
|
||||
SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class);
|
||||
|
||||
doThrow(new IOException()).when(signal).requestPushChallenge(any(), any());
|
||||
doThrow(new IOException()).when(signal).requestRegistrationPushChallenge(any(), any());
|
||||
|
||||
Optional<String> challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L);
|
||||
|
||||
|
|
|
@ -198,7 +198,7 @@ public class SignalServiceAccountManager {
|
|||
* @param e164number The number to associate it with.
|
||||
* @throws IOException
|
||||
*/
|
||||
public void requestPushChallenge(String gcmRegistrationId, String e164number) throws IOException {
|
||||
public void requestRegistrationPushChallenge(String gcmRegistrationId, String e164number) throws IOException {
|
||||
this.pushServiceSocket.requestPushChallenge(gcmRegistrationId, e164number);
|
||||
}
|
||||
|
||||
|
@ -711,6 +711,18 @@ public class SignalServiceAccountManager {
|
|||
this.pushServiceSocket.deleteAccount();
|
||||
}
|
||||
|
||||
public void requestRateLimitPushChallenge() throws IOException {
|
||||
this.pushServiceSocket.requestRateLimitPushChallenge();
|
||||
}
|
||||
|
||||
public void submitRateLimitPushChallenge(String challenge) throws IOException {
|
||||
this.pushServiceSocket.submitRateLimitPushChallenge(challenge);
|
||||
}
|
||||
|
||||
public void submitRateLimitRecaptchaChallenge(String challenge, String recaptchaToken) throws IOException {
|
||||
this.pushServiceSocket.submitRateLimitRecaptchaChallenge(challenge, recaptchaToken);
|
||||
}
|
||||
|
||||
public void setSoTimeoutMillis(long soTimeoutMillis) {
|
||||
this.pushServiceSocket.setSoTimeoutMillis(soTimeoutMillis);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ package org.whispersystems.signalservice.api;
|
|||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
|
@ -26,12 +25,14 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
|||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
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 org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentV2UploadAttributes;
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttributes;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
|
||||
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
|
||||
import org.whispersystems.signalservice.internal.push.SendMessageResponse;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
@ -196,6 +197,12 @@ public class SignalServiceMessagePipe {
|
|||
return FutureTransformers.map(response, value -> {
|
||||
if (value.getStatus() == 404) {
|
||||
throw new UnregisteredUserException(list.getDestination(), new NotFoundException("not found"));
|
||||
} else if (value.getStatus() == 428) {
|
||||
ProofRequiredResponse proofResponse = JsonUtil.fromJson(value.getBody(), ProofRequiredResponse.class);
|
||||
String retryAfterRaw = value.getHeader("Retry-After");
|
||||
long retryAfter = Util.parseInt(retryAfterRaw, -1);
|
||||
|
||||
throw new ProofRequiredException(proofResponse, retryAfter);
|
||||
} else if (value.getStatus() == 508) {
|
||||
throw new ServerRejectedException();
|
||||
} else if (value.getStatus() < 200 || value.getStatus() >= 300) {
|
||||
|
|
|
@ -55,6 +55,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
|||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
|
||||
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.api.push.exceptions.UnregisteredUserException;
|
||||
|
@ -68,6 +69,7 @@ import org.whispersystems.signalservice.internal.push.AttachmentV3UploadAttribut
|
|||
import org.whispersystems.signalservice.internal.push.MismatchedDevices;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage;
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessageList;
|
||||
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
|
||||
import org.whispersystems.signalservice.internal.push.ProvisioningProtos;
|
||||
import org.whispersystems.signalservice.internal.push.PushAttachmentData;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
|
@ -98,6 +100,7 @@ import java.io.IOException;
|
|||
import java.io.InputStream;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
|
@ -1424,6 +1427,9 @@ public class SignalServiceMessageSender {
|
|||
} else if (e.getCause() instanceof ServerRejectedException) {
|
||||
Log.w(TAG, e);
|
||||
throw ((ServerRejectedException) e.getCause());
|
||||
} else if (e.getCause() instanceof ProofRequiredException) {
|
||||
Log.w(TAG, e);
|
||||
results.add(SendMessageResult.proofRequiredFailure(recipient, (ProofRequiredException) e.getCause()));
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
|
|
@ -3,29 +3,35 @@ package org.whispersystems.signalservice.api.messages;
|
|||
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
|
||||
public class SendMessageResult {
|
||||
|
||||
private final SignalServiceAddress address;
|
||||
private final Success success;
|
||||
private final boolean networkFailure;
|
||||
private final boolean unregisteredFailure;
|
||||
private final IdentityFailure identityFailure;
|
||||
private final SignalServiceAddress address;
|
||||
private final Success success;
|
||||
private final boolean networkFailure;
|
||||
private final boolean unregisteredFailure;
|
||||
private final IdentityFailure identityFailure;
|
||||
private final ProofRequiredException proofRequiredFailure;
|
||||
|
||||
public static SendMessageResult success(SignalServiceAddress address, boolean unidentified, boolean needsSync, long duration) {
|
||||
return new SendMessageResult(address, new Success(unidentified, needsSync, duration), false, false, null);
|
||||
return new SendMessageResult(address, new Success(unidentified, needsSync, duration), false, false, null, null);
|
||||
}
|
||||
|
||||
public static SendMessageResult networkFailure(SignalServiceAddress address) {
|
||||
return new SendMessageResult(address, null, true, false, null);
|
||||
return new SendMessageResult(address, null, true, false, null, null);
|
||||
}
|
||||
|
||||
public static SendMessageResult unregisteredFailure(SignalServiceAddress address) {
|
||||
return new SendMessageResult(address, null, false, true, null);
|
||||
return new SendMessageResult(address, null, false, true, null, null);
|
||||
}
|
||||
|
||||
public static SendMessageResult identityFailure(SignalServiceAddress address, IdentityKey identityKey) {
|
||||
return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey));
|
||||
return new SendMessageResult(address, null, false, false, new IdentityFailure(identityKey), null);
|
||||
}
|
||||
|
||||
public static SendMessageResult proofRequiredFailure(SignalServiceAddress address, ProofRequiredException proofRequiredException) {
|
||||
return new SendMessageResult(address, null, false, false, null, proofRequiredException);
|
||||
}
|
||||
|
||||
public SignalServiceAddress getAddress() {
|
||||
|
@ -37,7 +43,7 @@ public class SendMessageResult {
|
|||
}
|
||||
|
||||
public boolean isNetworkFailure() {
|
||||
return networkFailure;
|
||||
return networkFailure || proofRequiredFailure != null;
|
||||
}
|
||||
|
||||
public boolean isUnregisteredFailure() {
|
||||
|
@ -48,12 +54,23 @@ public class SendMessageResult {
|
|||
return identityFailure;
|
||||
}
|
||||
|
||||
private SendMessageResult(SignalServiceAddress address, Success success, boolean networkFailure, boolean unregisteredFailure, IdentityFailure identityFailure) {
|
||||
public ProofRequiredException getProofRequiredFailure() {
|
||||
return proofRequiredFailure;
|
||||
}
|
||||
|
||||
private SendMessageResult(SignalServiceAddress address,
|
||||
Success success,
|
||||
boolean networkFailure,
|
||||
boolean unregisteredFailure,
|
||||
IdentityFailure identityFailure,
|
||||
ProofRequiredException proofRequiredFailure)
|
||||
{
|
||||
this.address = address;
|
||||
this.success = success;
|
||||
this.networkFailure = networkFailure;
|
||||
this.unregisteredFailure = unregisteredFailure;
|
||||
this.identityFailure = identityFailure;
|
||||
this.proofRequiredFailure = proofRequiredFailure;
|
||||
}
|
||||
|
||||
public static class Success {
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
import org.whispersystems.libsignal.logging.Log;
|
||||
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Thrown when rate-limited by the server and proof of humanity is required to continue messaging.
|
||||
*/
|
||||
public class ProofRequiredException extends NonSuccessfulResponseCodeException {
|
||||
private static final String TAG = "ProofRequiredRateLimit";
|
||||
|
||||
private final String token;
|
||||
private final Set<Option> options;
|
||||
private final long retryAfterSeconds;
|
||||
|
||||
public ProofRequiredException(ProofRequiredResponse response, long retryAfterSeconds) {
|
||||
super(428);
|
||||
|
||||
this.token = response.getToken();
|
||||
this.options = parseOptions(response.getOptions());
|
||||
this.retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public Set<Option> getOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
public long getRetryAfterSeconds() {
|
||||
return retryAfterSeconds;
|
||||
}
|
||||
|
||||
private static Set<Option> parseOptions(List<String> rawOptions) {
|
||||
Set<Option> options = new HashSet<>(rawOptions.size());
|
||||
|
||||
for (String raw : rawOptions) {
|
||||
switch (raw) {
|
||||
case "recaptcha":
|
||||
options.add(Option.RECAPTCHA);
|
||||
break;
|
||||
case "pushChallenge":
|
||||
options.add(Option.PUSH_CHALLENGE);
|
||||
break;
|
||||
default:
|
||||
Log.w(TAG, "Unrecognized challenge option: " + raw);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public enum Option {
|
||||
RECAPTCHA, PUSH_CHALLENGE
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ProofRequiredResponse {
|
||||
|
||||
@JsonProperty
|
||||
public String token;
|
||||
|
||||
@JsonProperty
|
||||
public List<String> options;
|
||||
|
||||
public ProofRequiredResponse() {}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public List<String> getOptions() {
|
||||
return options;
|
||||
}
|
||||
}
|
|
@ -60,6 +60,7 @@ import org.whispersystems.signalservice.api.push.exceptions.MissingConfiguration
|
|||
import org.whispersystems.signalservice.api.push.exceptions.NoContentException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RangeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
|
@ -221,6 +222,9 @@ public class PushServiceSocket {
|
|||
|
||||
private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions";
|
||||
|
||||
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
|
||||
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
|
||||
|
||||
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
|
||||
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
|
||||
|
@ -770,6 +774,20 @@ public class PushServiceSocket {
|
|||
makeServiceRequest(DELETE_ACCOUNT_PATH, "DELETE", null);
|
||||
}
|
||||
|
||||
public void requestRateLimitPushChallenge() throws IOException {
|
||||
makeServiceRequest(REQUEST_RATE_LIMIT_PUSH_CHALLENGE, "POST", "");
|
||||
}
|
||||
|
||||
public void submitRateLimitPushChallenge(String challenge) throws IOException {
|
||||
String payload = JsonUtil.toJson(new SubmitPushChallengePayload(challenge));
|
||||
makeServiceRequest(SUBMIT_RATE_LIMIT_CHALLENGE, "PUT", payload);
|
||||
}
|
||||
|
||||
public void submitRateLimitRecaptchaChallenge(String challenge, String recaptchaToken) throws IOException {
|
||||
String payload = JsonUtil.toJson(new SubmitRecaptchaChallengePayload(challenge, recaptchaToken));
|
||||
makeServiceRequest(SUBMIT_RATE_LIMIT_CHALLENGE, "PUT", payload);
|
||||
}
|
||||
|
||||
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
|
||||
{
|
||||
|
@ -1468,6 +1486,13 @@ public class PushServiceSocket {
|
|||
throw new LockedException(accountLockFailure.length,
|
||||
accountLockFailure.timeRemaining,
|
||||
basicStorageCredentials);
|
||||
case 428:
|
||||
ProofRequiredResponse proofRequiredResponse = readResponseJson(response, ProofRequiredResponse.class);
|
||||
String retryAfterRaw = response.header("Retry-After");
|
||||
long retryAfter = Util.parseInt(retryAfterRaw, -1);
|
||||
|
||||
throw new ProofRequiredException(proofRequiredResponse, retryAfter);
|
||||
|
||||
case 499:
|
||||
throw new DeprecatedVersionException();
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
class SubmitPushChallengePayload {
|
||||
|
||||
@JsonProperty
|
||||
private String type;
|
||||
|
||||
@JsonProperty
|
||||
private String challenge;
|
||||
|
||||
public SubmitPushChallengePayload() {}
|
||||
|
||||
public SubmitPushChallengePayload(String challenge) {
|
||||
this.type = "rateLimitPushChallenge";
|
||||
this.challenge = challenge;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
class SubmitRecaptchaChallengePayload {
|
||||
|
||||
@JsonProperty
|
||||
private String type;
|
||||
|
||||
@JsonProperty
|
||||
private String token;
|
||||
|
||||
@JsonProperty
|
||||
private String captcha;
|
||||
|
||||
public SubmitRecaptchaChallengePayload() {}
|
||||
|
||||
public SubmitRecaptchaChallengePayload(String challenge, String recaptchaToken) {
|
||||
this.type = "recaptcha";
|
||||
this.token = challenge;
|
||||
this.captcha = recaptchaToken;
|
||||
}
|
||||
}
|
|
@ -150,4 +150,11 @@ public class Util {
|
|||
return Collections.unmodifiableList(Arrays.asList(elements.clone()));
|
||||
}
|
||||
|
||||
public static int parseInt(String integer, int defaultValue) {
|
||||
try {
|
||||
return Integer.parseInt(integer);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -255,7 +255,8 @@ public class WebSocketConnection extends WebSocketListener {
|
|||
OutgoingRequest listener = outgoingRequests.get(message.getResponse().getId());
|
||||
if (listener != null) {
|
||||
listener.getResponseFuture().set(new WebsocketResponse(message.getResponse().getStatus(),
|
||||
new String(message.getResponse().getBody().toByteArray())));
|
||||
new String(message.getResponse().getBody().toByteArray()),
|
||||
message.getResponse().getHeadersList()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
package org.whispersystems.signalservice.internal.websocket;
|
||||
|
||||
public class WebsocketResponse {
|
||||
private final int status;
|
||||
private final String body;
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
|
||||
WebsocketResponse(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class WebsocketResponse {
|
||||
private final int status;
|
||||
private final String body;
|
||||
private final Map<String, String> headers;
|
||||
|
||||
WebsocketResponse(int status, String body, List<String> headers) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.headers = parseHeaders(headers);
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
|
@ -16,4 +24,27 @@ public class WebsocketResponse {
|
|||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public String getHeader(String key) {
|
||||
return headers.get(Preconditions.checkNotNull(key.toLowerCase()));
|
||||
}
|
||||
|
||||
private static Map<String, String> parseHeaders(List<String> rawHeaders) {
|
||||
Map<String, String> headers = new HashMap<>(rawHeaders.size());
|
||||
|
||||
for (String raw : rawHeaders) {
|
||||
if (raw != null && raw.length() > 0) {
|
||||
int colonIndex = raw.indexOf(":");
|
||||
|
||||
if (colonIndex > 0 && colonIndex < raw.length() - 1) {
|
||||
String key = raw.substring(0, colonIndex).trim().toLowerCase();
|
||||
String value = raw.substring(colonIndex + 1).trim();
|
||||
|
||||
headers.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue