threadIds) {
Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")");
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
index 8e657ee292..3ab69763d9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java
@@ -28,7 +28,34 @@ public interface MmsSmsColumns {
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
public static final String REMOTE_DELETED = "remote_deleted";
+ /**
+ * For storage efficiency, all types are stored within a single 64-bit integer column in the
+ * database. There are various areas reserved for different classes of data.
+ *
+ * When carving out a new area, if it's storing a bunch of mutually-exclusive flags (like in
+ * {@link #BASE_TYPE_MASK}, you should store integers in that area. If multiple flags can be set
+ * within a category, you'll have to store them as bits. Just keep in mind that storing as bits
+ * means we can store less data (i.e. 4 bits can store 16 exclusive values, or 4 non-exclusive
+ * values). This was not always followed in the past, and now we've wasted some space.
+ *
+ * Note: We technically could use up to 64 bits, but {@link #TOTAL_MASK} is currently just set to
+ * look at 32. Theoretically if we needed more bits, we could just use them and expand the size of
+ * {@link #TOTAL_MASK}.
+ *
+ *
+ * _____________________________________ ENCRYPTION ({@link #ENCRYPTION_MASK})
+ * | _____________________________ SECURE MESSAGE INFORMATION (no mask, but look at {@link #SECURE_MESSAGE_BIT})
+ * | | ________________________ GROUPS (no mask, but look at {@link #GROUP_UPDATE_BIT})
+ * | | | _________________ KEY_EXCHANGE ({@link #KEY_EXCHANGE_MASK})
+ * | | | | _________ MESSAGE_ATTRIBUTES ({@link #MESSAGE_ATTRIBUTE_MASK})
+ * | | | | | ____ BASE_TYPE ({@link #BASE_TYPE_MASK})
+ * ___|___ _| _| ___|__ | __|_
+ * | | | | | | | | | || |
+ * 0000 0000 0000 0000 0000 0000 0000 0000
+ *
+ */
public static class Types {
+
protected static final long TOTAL_MASK = 0xFFFFFFFF;
// Base Types
@@ -63,8 +90,10 @@ public interface MmsSmsColumns {
OUTGOING_AUDIO_CALL_TYPE, OUTGOING_VIDEO_CALL_TYPE};
// Message attributes
- protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0;
- protected static final long MESSAGE_FORCE_SMS_BIT = 0x40;
+ protected static final long MESSAGE_ATTRIBUTE_MASK = 0xE0;
+ protected static final long MESSAGE_RATE_LIMITED_BIT = 0x80;
+ protected static final long MESSAGE_FORCE_SMS_BIT = 0x40;
+ // Note: Might be wise to reserve 0x20 -- it would let us expand BASE_MASK by a bit if needed
// Key Exchange Information
protected static final long KEY_EXCHANGE_MASK = 0xFF00;
@@ -210,6 +239,10 @@ public interface MmsSmsColumns {
return (type & KEY_EXCHANGE_IDENTITY_UPDATE_BIT) != 0;
}
+ public static boolean isRateLimited(long type) {
+ return (type & MESSAGE_RATE_LIMITED_BIT) != 0;
+ }
+
public static boolean isCallLog(long type) {
return isIncomingAudioCall(type) ||
isIncomingVideoCall(type) ||
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
index 0a478335fa..f1ba08d4d8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -47,7 +47,6 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.mms.IncomingMediaMessage;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
-import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo;
@@ -321,6 +320,27 @@ public class SmsDatabase extends MessageDatabase {
updateTypeBitmask(id, Types.PUSH_MESSAGE_BIT, Types.MESSAGE_FORCE_SMS_BIT);
}
+ @Override
+ public void markAsRateLimited(long id) {
+ updateTypeBitmask(id, 0, Types.MESSAGE_RATE_LIMITED_BIT);
+ }
+
+ @Override
+ public void clearRateLimitStatus(@NonNull Collection ids) {
+ SQLiteDatabase db = databaseHelper.getWritableDatabase();
+
+ db.beginTransaction();
+ try {
+ for (long id : ids) {
+ updateTypeBitmask(id, Types.MESSAGE_RATE_LIMITED_BIT, 0);
+ }
+
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
@Override
public void markAsDecryptFailed(long id) {
updateTypeBitmask(id, Types.ENCRYPTION_MASK, Types.ENCRYPTION_REMOTE_FAILED_BIT);
@@ -887,6 +907,22 @@ public class SmsDatabase extends MessageDatabase {
return new Pair<>(messageId, threadId);
}
+ @Override
+ public Set getAllRateLimitedMessageIds() {
+ SQLiteDatabase db = databaseHelper.getReadableDatabase();
+ String where = "(" + TYPE + " & " + Types.TOTAL_MASK + " & " + Types.MESSAGE_RATE_LIMITED_BIT + ") > 0";
+
+ Set ids = new HashSet<>();
+
+ try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) {
+ while (cursor.moveToNext()) {
+ ids.add(CursorUtil.requireLong(cursor, ID));
+ }
+ }
+
+ return ids;
+ }
+
@Override
public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) {
String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?";
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
index 5cd33f5741..cb31010135 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java
@@ -423,6 +423,10 @@ public abstract class MessageRecord extends DisplayRecord {
return SmsDatabase.Types.isContentBundleKeyExchange(type);
}
+ public boolean isRateLimited() {
+ return SmsDatabase.Types.isRateLimited(type);
+ }
+
public boolean isIdentityUpdate() {
return SmsDatabase.Types.isIdentityUpdate(type);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java
index 54f1bcb4ec..1da84b91ff 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java
@@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
+import org.thoughtcrime.securesms.jobs.SubmitRateLimitPushChallengeJob;
import org.thoughtcrime.securesms.registration.PushChallengeRequest;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -30,10 +31,14 @@ public class FcmReceiveService extends FirebaseMessagingService {
remoteMessage.getPriority(),
remoteMessage.getOriginalPriority()));
- String challenge = remoteMessage.getData().get("challenge");
- if (challenge != null) {
- handlePushChallenge(challenge);
- } else {
+ String registrationChallenge = remoteMessage.getData().get("challenge");
+ String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
+
+ if (registrationChallenge != null) {
+ handleRegistrationPushChallenge(registrationChallenge);
+ } else if (rateLimitChallenge != null) {
+ handleRateLimitPushChallenge(rateLimitChallenge);
+ }else {
handleReceivedNotification(ApplicationDependencies.getApplication());
}
}
@@ -75,9 +80,13 @@ public class FcmReceiveService extends FirebaseMessagingService {
}
}
- private static void handlePushChallenge(@NonNull String challenge) {
- Log.d(TAG, String.format("Got a push challenge \"%s\"", challenge));
-
+ private static void handleRegistrationPushChallenge(@NonNull String challenge) {
+ Log.d(TAG, "Got a registration push challenge.");
PushChallengeRequest.postChallengeResponse(challenge);
}
+
+ private static void handleRateLimitPushChallenge(@NonNull String challenge) {
+ Log.d(TAG, "Got a rate limit push challenge.");
+ ApplicationDependencies.getJobManager().add(new SubmitRateLimitPushChallengeJob(challenge));
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java
index c406ec09d6..51cd4745c1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java
@@ -176,6 +176,23 @@ class JobController {
.forEach(this::cancelJob);
}
+ @WorkerThread
+ synchronized void update(@NonNull JobUpdater updater) {
+ List allJobs = jobStorage.getAllJobSpecs();
+ List updatedJobs = new LinkedList<>();
+
+ for (JobSpec job : allJobs) {
+ JobSpec updated = updater.update(job, dataSerializer);
+ if (updated != job) {
+ updatedJobs.add(updated);
+ }
+ }
+
+ jobStorage.updateJobs(updatedJobs);
+
+ notifyAll();
+ }
+
@WorkerThread
synchronized void onRetry(@NonNull Job job, long backoffInterval) {
if (backoffInterval <= 0) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java
index 5ebb22a435..ee507b908b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java
@@ -223,6 +223,15 @@ public class JobManager implements ConstraintObserver.Notifier {
runOnExecutor(() -> jobController.cancelAllInQueue(queue));
}
+ /**
+ * Perform an arbitrary update on enqueued jobs. Will not apply to jobs that are already running.
+ * You shouldn't use this if you can help it. You give yourself an opportunity to really screw
+ * things up.
+ */
+ public void update(@NonNull JobUpdater updater) {
+ runOnExecutor(() -> jobController.update(updater));
+ }
+
/**
* Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning
* you must take great care where you call this. It could take a very long time to complete!
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java
new file mode 100644
index 0000000000..6a4ca51b1a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobUpdater.java
@@ -0,0 +1,19 @@
+package org.thoughtcrime.securesms.jobmanager;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec;
+
+public interface JobUpdater {
+ /**
+ * Called for each enqueued job, giving you an opportunity to update each one.
+ *
+ * @param jobSpec An object representing data about an enqueued job.
+ * @param serializer An object that can be used to serialize/deserialize data if necessary for
+ * your update.
+ *
+ * @return The updated JobSpec you want persisted. If you do not wish to make an update, return
+ * the literal same JobSpec instance you were provided.
+ */
+ @NonNull JobSpec update(@NonNull JobSpec jobSpec, @NonNull Data.Serializer serializer);
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java
index 7c6f96541b..422e7b9c45 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/persistence/JobSpec.java
@@ -49,6 +49,10 @@ public final class JobSpec {
this.memoryOnly = memoryOnly;
}
+ public @NonNull JobSpec withNextRunAttemptTime(long updated) {
+ return new JobSpec(id, factoryKey, queueKey, createTime, updated, runAttempt, maxAttempts, lifespan, serializedData, serializedInputData, isRunning, memoryOnly);
+ }
+
public @NonNull String getId() {
return id;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 53c8e2f5a9..a02034cbe8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -144,7 +144,8 @@ public final class JobManagerFactories {
put(StickerDownloadJob.KEY, new StickerDownloadJob.Factory());
put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory());
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
- put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
+ put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
+ put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
put(TypingSendJob.KEY, new TypingSendJob.Factory());
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
index a5dde5bc7d..93da38e547 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
@@ -11,7 +11,6 @@ import com.annimon.stream.Stream;
import com.google.protobuf.ByteString;
import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -55,6 +54,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2;
@@ -151,7 +151,7 @@ public final class PushGroupSendJob extends PushSendJob {
@Override
public void onPushSend()
- throws IOException, MmsException, NoSuchMessageException, RetryLaterException
+ throws IOException, MmsException, NoSuchMessageException, RetryLaterException
{
MessageDatabase database = DatabaseFactory.getMmsDatabase(context);
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
@@ -197,6 +197,7 @@ public final class PushGroupSendJob extends PushSendJob {
List networkFailures = Stream.of(results).filter(SendMessageResult::isNetworkFailure).map(result -> new NetworkFailure(findId(result.getAddress(), idByE164, idByUuid))).toList();
List identityMismatches = Stream.of(results).filter(result -> result.getIdentityFailure() != null).map(result -> new IdentityKeyMismatch(findId(result.getAddress(), idByE164, idByUuid), result.getIdentityFailure().getIdentityKey())).toList();
+ ProofRequiredException proofRequired = Stream.of(results).filter(r -> r.getProofRequiredFailure() != null).findLast().map(SendMessageResult::getProofRequiredFailure).orElse(null);
List successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(findId(result.getAddress(), idByE164, idByUuid), result.getSuccess().isUnidentified())).toList();
Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet());
@@ -229,6 +230,10 @@ public final class PushGroupSendJob extends PushSendJob {
DatabaseFactory.getGroupReceiptDatabase(context).setUnidentified(successUnidentifiedStatus, messageId);
+ if (proofRequired != null) {
+ handleProofRequiredException(proofRequired, groupRecipient, threadId, messageId, true);
+ }
+
if (existingNetworkFailures.isEmpty() && networkFailures.isEmpty() && identityMismatches.isEmpty() && existingIdentityMismatches.isEmpty()) {
database.markAsSent(messageId, true);
@@ -391,6 +396,10 @@ public final class PushGroupSendJob extends PushSendJob {
return RecipientUtil.getEligibleForSending(members);
}
+ public static long getMessageId(@NonNull Data data) {
+ return data.getLong(KEY_MESSAGE_ID);
+ }
+
public static class Factory implements Job.Factory {
@Override
public @NonNull PushGroupSendJob create(@NonNull Parameters parameters, @NonNull org.thoughtcrime.securesms.jobmanager.Data data) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
index 8a753d690d..b7c64172d6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java
@@ -8,7 +8,6 @@ import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -41,6 +40,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Pr
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
@@ -105,7 +105,7 @@ public class PushMediaSendJob extends PushSendJob {
@Override
public void onPushSend()
- throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException
+ throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException
{
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageDatabase database = DatabaseFactory.getMmsDatabase(context);
@@ -172,6 +172,8 @@ public class PushMediaSendJob extends PushSendJob {
database.addMismatchedIdentity(messageId, recipientId, uie.getIdentityKey());
database.markAsSentFailed(messageId);
RetrieveProfileJob.enqueue(recipientId);
+ } catch (ProofRequiredException e) {
+ handleProofRequiredException(e, DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId), threadId, messageId, true);
}
}
@@ -235,6 +237,10 @@ public class PushMediaSendJob extends PushSendJob {
}
}
+ public static long getMessageId(@NonNull Data data) {
+ return data.getLong(KEY_MESSAGE_ID);
+ }
+
public static final class Factory implements Job.Factory {
@Override
public @NonNull PushMediaSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java
index 1bc64bb1de..ec554fd625 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java
@@ -11,6 +11,9 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
@@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
+import org.thoughtcrime.securesms.registration.PushChallengeRequest;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
@@ -49,6 +53,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@@ -60,7 +65,10 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
+import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
+import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
+import org.whispersystems.signalservice.internal.push.ProofRequiredResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -72,12 +80,14 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public abstract class PushSendJob extends SendJob {
private static final String TAG = Log.tag(PushSendJob.class);
private static final long CERTIFICATE_EXPIRATION_BUFFER = TimeUnit.DAYS.toMillis(1);
+ private static final long PUSH_CHALLENGE_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
protected PushSendJob(Job.Parameters parameters) {
super(parameters);
@@ -100,6 +110,11 @@ public abstract class PushSendJob extends SendJob {
}
onPushSend();
+
+ if (SignalStore.rateLimit().needsRecaptcha()) {
+ Log.i(TAG, "Successfully sent message. Assuming reCAPTCHA no longer needed.");
+ SignalStore.rateLimit().onProofAccepted();
+ }
}
@Override
@@ -124,16 +139,28 @@ public abstract class PushSendJob extends SendJob {
return false;
}
- return exception instanceof IOException ||
- exception instanceof RetryLaterException;
+ return exception instanceof IOException ||
+ exception instanceof RetryLaterException ||
+ exception instanceof ProofRequiredException;
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
- if (exception instanceof NonSuccessfulResponseCodeException) {
+ if (exception instanceof ProofRequiredException) {
+ long backoff = ((ProofRequiredException) exception).getRetryAfterSeconds();
+ warn(TAG, "[Proof Required] Retry-After is " + backoff + " seconds.");
+ if (backoff >= 0) {
+ return TimeUnit.SECONDS.toMillis(backoff);
+ }
+ } else if (exception instanceof NonSuccessfulResponseCodeException) {
if (((NonSuccessfulResponseCodeException) exception).is5xx()) {
return BackoffUtil.exponentialBackoff(pastAttemptCount, FeatureFlags.getServerErrorMaxBackoff());
}
+ } else if (exception instanceof RetryLaterException) {
+ long backoff = ((RetryLaterException) exception).getBackoff();
+ if (backoff >= 0) {
+ return backoff;
+ }
}
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
@@ -422,6 +449,81 @@ public abstract class PushSendJob extends SendJob {
return SignalServiceSyncMessage.forSentTranscript(transcript);
}
+ protected void handleProofRequiredException(@NonNull ProofRequiredException proofRequired, @Nullable Recipient recipient, long threadId, long messageId, boolean isMms)
+ throws ProofRequiredException, RetryLaterException
+ {
+ try {
+ if (proofRequired.getOptions().contains(ProofRequiredException.Option.PUSH_CHALLENGE)) {
+ ApplicationDependencies.getSignalServiceAccountManager().requestRateLimitPushChallenge();
+ log(TAG, "[Proof Required] Successfully requested a challenge. Waiting up to " + PUSH_CHALLENGE_TIMEOUT + " ms.");
+
+ boolean success = new PushChallengeRequest(PUSH_CHALLENGE_TIMEOUT).blockUntilSuccess();
+
+ if (success) {
+ log(TAG, "Successfully responded to a push challenge. Retrying message send.");
+ throw new RetryLaterException(1);
+ } else {
+ warn(TAG, "Failed to respond to the push challenge in time. Falling back.");
+ }
+ }
+ } catch (NonSuccessfulResponseCodeException e) {
+ warn(TAG, "[Proof Required] Could not request a push challenge (" + e.getCode() + "). Falling back.", e);
+ } catch (IOException e) {
+ warn(TAG, "[Proof Required] Network error when requesting push challenge. Retrying later.");
+ throw new RetryLaterException(e);
+ }
+
+ warn(TAG, "[Proof Required] Marking message as rate-limited. (id: " + messageId + ", mms: " + isMms + ", thread: " + threadId + ")");
+ if (isMms) {
+ DatabaseFactory.getMmsDatabase(context).markAsRateLimited(messageId);
+ } else {
+ DatabaseFactory.getSmsDatabase(context).markAsRateLimited(messageId);
+ }
+
+ if (proofRequired.getOptions().contains(ProofRequiredException.Option.RECAPTCHA)) {
+ log(TAG, "[Proof Required] ReCAPTCHA required.");
+ SignalStore.rateLimit().markNeedsRecaptcha(proofRequired.getToken());
+
+ if (recipient != null) {
+ ApplicationDependencies.getMessageNotifier().notifyProofRequired(context, recipient, threadId);
+ } else {
+ warn(TAG, "[Proof Required] No recipient! Couldn't notify.");
+ }
+ }
+
+ throw proofRequired;
+ }
protected abstract void onPushSend() throws Exception;
+
+ public static class PushChallengeRequest {
+ private final long timeout;
+ private final CountDownLatch latch;
+ private final EventBus eventBus;
+
+ private PushChallengeRequest(long timeout) {
+ this.timeout = timeout;
+ this.latch = new CountDownLatch(1);
+ this.eventBus = EventBus.getDefault();
+ }
+
+ public boolean blockUntilSuccess() {
+ eventBus.register(this);
+
+ try {
+ return latch.await(timeout, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Log.w(TAG, "[Proof Required] Interrupted?", e);
+ return false;
+ } finally {
+ eventBus.unregister(this);
+ }
+ }
+
+ @Subscribe(threadMode = ThreadMode.POSTING)
+ public void onSuccessReceived(SubmitRateLimitPushChallengeJob.SuccessEvent event) {
+ Log.i(TAG, "[Proof Required] Received a successful result!");
+ latch.countDown();
+ }
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java
index 9f995b722e..d17f6d5046 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
-import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
@@ -14,7 +13,6 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
-import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -31,12 +29,11 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
-import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
+import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
import java.io.IOException;
-import java.util.concurrent.TimeUnit;
public class PushTextSendJob extends PushSendJob {
@@ -73,7 +70,7 @@ public class PushTextSendJob extends PushSendJob {
}
@Override
- public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException {
+ public void onPushSend() throws IOException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException {
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageDatabase database = DatabaseFactory.getSmsDatabase(context);
SmsMessageRecord record = database.getSmsMessage(messageId);
@@ -133,6 +130,8 @@ public class PushTextSendJob extends PushSendJob {
database.markAsSentFailed(record.getId());
database.markAsPush(record.getId());
RetrieveProfileJob.enqueue(recipientId);
+ } catch (ProofRequiredException e) {
+ handleProofRequiredException(e, record.getRecipient(), record.getThreadId(), messageId, false);
}
}
@@ -187,6 +186,10 @@ public class PushTextSendJob extends PushSendJob {
}
}
+ public static long getMessageId(@NonNull Data data) {
+ return data.getLong(KEY_MESSAGE_ID);
+ }
+
public static class Factory implements Job.Factory {
@Override
public @NonNull PushTextSendJob create(@NonNull Parameters parameters, @NonNull Data data) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java
index 45ff1d9cb7..ebc5e6f9fc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RotateProfileKeyJob.java
@@ -47,6 +47,7 @@ public class RotateProfileKeyJob extends BaseJob {
ApplicationDependencies.getJobManager().add(new ProfileUploadJob());
ApplicationDependencies.getJobManager().add(new RefreshAttributesJob());
+ ApplicationDependencies.getJobManager().add(new MultiDeviceProfileKeyUpdateJob());
updateProfileKeyOnAllV2Groups();
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java
new file mode 100644
index 0000000000..5f224f017b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubmitRateLimitPushChallengeJob.java
@@ -0,0 +1,77 @@
+package org.thoughtcrime.securesms.jobs;
+
+import androidx.annotation.NonNull;
+
+import org.greenrobot.eventbus.EventBus;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.jobmanager.Data;
+import org.thoughtcrime.securesms.jobmanager.Job;
+import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
+import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Send a push challenge token to the service as a way of proving that your device has FCM.
+ */
+public final class SubmitRateLimitPushChallengeJob extends BaseJob {
+
+ public static final String KEY = "SubmitRateLimitPushChallengeJob";
+
+ private static final String KEY_CHALLENGE = "challenge";
+
+ private final String challenge;
+
+ public SubmitRateLimitPushChallengeJob(@NonNull String challenge) {
+ this(new Parameters.Builder()
+ .addConstraint(NetworkConstraint.KEY)
+ .setLifespan(TimeUnit.HOURS.toMillis(1))
+ .setMaxAttempts(Parameters.UNLIMITED)
+ .build(),
+ challenge);
+ }
+
+ private SubmitRateLimitPushChallengeJob(@NonNull Parameters parameters, @NonNull String challenge) {
+ super(parameters);
+ this.challenge = challenge;
+ }
+
+ @Override
+ public @NonNull Data serialize() {
+ return new Data.Builder().putString(KEY_CHALLENGE, challenge).build();
+ }
+
+ @Override
+ public @NonNull String getFactoryKey() {
+ return KEY;
+ }
+
+ @Override
+ protected void onRun() throws Exception {
+ ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitPushChallenge(challenge);
+ SignalStore.rateLimit().onProofAccepted();
+ EventBus.getDefault().post(new SuccessEvent());
+ RateLimitUtil.retryAllRateLimitedMessages(context);
+ }
+
+ @Override
+ protected boolean onShouldRetry(@NonNull Exception e) {
+ return e instanceof PushNetworkException;
+ }
+
+ @Override
+ public void onFailure() {
+ }
+
+ public static final class SuccessEvent {
+ }
+
+ public static class Factory implements Job.Factory {
+ @Override
+ public @NonNull SubmitRateLimitPushChallengeJob create(@NonNull Parameters parameters, @NonNull Data data) {
+ return new SubmitRateLimitPushChallengeJob(parameters, data.getString(KEY_CHALLENGE));
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java
new file mode 100644
index 0000000000..dd208f5750
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RateLimitValues.java
@@ -0,0 +1,55 @@
+package org.thoughtcrime.securesms.keyvalue;
+
+import androidx.annotation.NonNull;
+
+import org.greenrobot.eventbus.EventBus;
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent;
+
+import java.util.Collections;
+import java.util.List;
+
+public final class RateLimitValues extends SignalStoreValues {
+
+ private static final String TAG = Log.tag(RateLimitValues.class);
+
+ private static final String KEY_NEEDS_RECAPTCHA = "ratelimit.needs_recaptcha";
+ private static final String KEY_CHALLENGE = "ratelimit.token";
+
+ RateLimitValues(@NonNull KeyValueStore store) {
+ super(store);
+ }
+
+ @Override
+ void onFirstEverAppLaunch() {
+ }
+
+ @Override
+ @NonNull List getKeysToIncludeInBackup() {
+ return Collections.emptyList();
+ }
+
+ /**
+ * @param challenge The token associated with the rate limit response.
+ */
+ public void markNeedsRecaptcha(@NonNull String challenge) {
+ Log.i(TAG, "markNeedsRecaptcha()");
+ putBoolean(KEY_NEEDS_RECAPTCHA, true);
+ putString(KEY_CHALLENGE, challenge);
+ EventBus.getDefault().post(new RecaptchaRequiredEvent());
+ }
+
+ public void onProofAccepted() {
+ Log.i(TAG, "onProofAccepted()", new Throwable());
+ putBoolean(KEY_NEEDS_RECAPTCHA, false);
+ remove(KEY_CHALLENGE);
+ }
+
+ public boolean needsRecaptcha() {
+ return getBoolean(KEY_NEEDS_RECAPTCHA, false);
+ }
+
+ public @NonNull String getChallenge() {
+ return getString(KEY_CHALLENGE, "");
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
index 5bbffadf61..9aa5f130e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
@@ -35,6 +35,7 @@ public final class SignalStore {
private final WallpaperValues wallpaperValues;
private final PaymentsValues paymentsValues;
private final ProxyValues proxyValues;
+ private final RateLimitValues rateLimitValues;
private SignalStore() {
this.store = new KeyValueStore(ApplicationDependencies.getApplication());
@@ -55,6 +56,7 @@ public final class SignalStore {
this.wallpaperValues = new WallpaperValues(store);
this.paymentsValues = new PaymentsValues(store);
this.proxyValues = new ProxyValues(store);
+ this.rateLimitValues = new RateLimitValues(store);
}
public static void onFirstEverAppLaunch() {
@@ -75,6 +77,7 @@ public final class SignalStore {
wallpaper().onFirstEverAppLaunch();
paymentsValues().onFirstEverAppLaunch();
proxy().onFirstEverAppLaunch();
+ rateLimit().onFirstEverAppLaunch();
}
public static List getKeysToIncludeInBackup() {
@@ -96,6 +99,7 @@ public final class SignalStore {
keys.addAll(wallpaper().getKeysToIncludeInBackup());
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
keys.addAll(proxy().getKeysToIncludeInBackup());
+ keys.addAll(rateLimit().getKeysToIncludeInBackup());
return keys;
}
@@ -176,6 +180,10 @@ public final class SignalStore {
return INSTANCE.proxyValues;
}
+ public static @NonNull RateLimitValues rateLimit() {
+ return INSTANCE.rateLimitValues;
+ }
+
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore());
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java
index f7f2e1be1e..cf69756e82 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValues.java
@@ -67,4 +67,8 @@ abstract class SignalStoreValues {
void putString(@NonNull String key, String value) {
store.beginWrite().putString(key, value).apply();
}
+
+ void remove(@NonNull String key) {
+ store.beginWrite().remove(key);
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
index 358dc9e97f..7845a52c81 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
@@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress;
+import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
@@ -944,6 +945,11 @@ public final class MessageContentProcessor {
ApplicationDependencies.getMessageNotifier().updateNotification(context);
}
+ if (SignalStore.rateLimit().needsRecaptcha()) {
+ Log.i(TAG, "Got a sent transcript while in reCAPTCHA mode. Assuming we're good to message again.");
+ RateLimitUtil.retryAllRateLimitedMessages(context);
+ }
+
ApplicationDependencies.getMessageNotifier().setLastDesktopActivityTimestamp(message.getTimestamp());
} catch (MmsException e) {
throw new StorageFailedException(e, content.getSender().getIdentifier(), content.getSenderDevice());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
index bee9792729..648854a63d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java
@@ -23,6 +23,7 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
+import android.graphics.BitmapFactory;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.Ringtone;
@@ -131,7 +132,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
@Override
- public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) {
+ public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
if (visibleThread == threadId) {
sendInThreadNotification(context, recipient);
} else {
@@ -145,6 +146,15 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
}
+ @Override
+ public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
+ if (visibleThread == threadId) {
+ sendInThreadNotification(context, recipient);
+ } else {
+ Log.w(TAG, "[Proof Required] Not notifying on old notifier.");
+ }
+ }
+
@Override
public void cancelDelayedNotifications() {
executor.cancel();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java
index 138c995804..40ad22c287 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java
@@ -16,7 +16,8 @@ public interface MessageNotifier {
long getVisibleThread();
void clearVisibleThread();
void setLastDesktopActivityTimestamp(long timestamp);
- void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId);
+ void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId);
+ void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId);
void cancelDelayedNotifications();
void updateNotification(@NonNull Context context);
void updateNotification(@NonNull Context context, long threadId);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java
index a77381345d..406fd644c5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java
@@ -53,10 +53,15 @@ public class OptimizedMessageNotifier implements MessageNotifier {
}
@Override
- public void notifyMessageDeliveryFailed(Context context, Recipient recipient, long threadId) {
+ public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
getNotifier().notifyMessageDeliveryFailed(context, recipient, threadId);
}
+ @Override
+ public void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
+ getNotifier().notifyProofRequired(context, recipient, threadId);
+ }
+
@Override
public void cancelDelayedNotifications() {
getNotifier().cancelDelayedNotifications();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt
index 606e9ec14e..4059db6022 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt
@@ -76,6 +76,10 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
NotificationFactory.notifyMessageDeliveryFailed(context, recipient, threadId, visibleThread)
}
+ override fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long) {
+ NotificationFactory.notifyProofRequired(context, recipient, threadId, visibleThread)
+ }
+
override fun cancelDelayedNotifications() {
executor.cancel()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt
index 97b0948466..87a9b91775 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt
@@ -315,6 +315,33 @@ object NotificationFactory {
NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build())
}
+ fun notifyProofRequired(context: Context, recipient: Recipient, threadId: Long, visibleThread: Long) {
+ if (threadId == visibleThread) {
+ notifyInThread(context, recipient, 0)
+ return
+ }
+
+ val intent: Intent = ConversationIntents.createBuilder(context, recipient.id, threadId)
+ .build()
+ .makeUniqueToPreventMerging()
+
+ val builder: NotificationBuilder = NotificationBuilder.create(context)
+
+ builder.apply {
+ setSmallIcon(R.drawable.ic_notification)
+ setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_info_outline))
+ setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_paused))
+ setContentText(context.getString(R.string.MessageNotifier_verify_to_continue_messaging_on_signal))
+ setContentIntent(PendingIntent.getActivity(context, 0, intent, 0))
+ setOnlyAlertOnce(true)
+ setAutoCancel(true)
+ setAlarms(recipient)
+ setChannelId(NotificationChannels.FAILURES)
+ }
+
+ NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build())
+ }
+
private fun NotificationManagerCompat.safelyNotify(context: Context, threadRecipient: Recipient?, notificationId: Int, notification: Notification) {
try {
notify(notificationId, notification)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java
new file mode 100644
index 0000000000..479a4e860a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RateLimitUtil.java
@@ -0,0 +1,55 @@
+package org.thoughtcrime.securesms.ratelimit;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.jobmanager.Data;
+import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
+import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
+import org.thoughtcrime.securesms.jobs.PushTextSendJob;
+
+import java.util.Set;
+
+public final class RateLimitUtil {
+
+ private static final String TAG = Log.tag(RateLimitUtil.class);
+
+ private RateLimitUtil() {}
+
+ /**
+ * Forces a retry of all rate limited messages by editing jobs that are in the queue.
+ */
+ @WorkerThread
+ public static void retryAllRateLimitedMessages(@NonNull Context context) {
+ Set sms = DatabaseFactory.getSmsDatabase(context).getAllRateLimitedMessageIds();
+ Set mms = DatabaseFactory.getMmsDatabase(context).getAllRateLimitedMessageIds();
+
+ if (sms.isEmpty() && mms.isEmpty()) {
+ return;
+ }
+
+ Log.i(TAG, "Retrying " + sms.size() + " sms records and " + mms.size() + " mms records.");
+
+ DatabaseFactory.getSmsDatabase(context).clearRateLimitStatus(sms);
+ DatabaseFactory.getMmsDatabase(context).clearRateLimitStatus(mms);
+
+ ApplicationDependencies.getJobManager().update((job, serializer) -> {
+ Data data = serializer.deserialize(job.getSerializedData());
+
+ if (job.getFactoryKey().equals(PushTextSendJob.KEY) && sms.contains(PushTextSendJob.getMessageId(data))) {
+ return job.withNextRunAttemptTime(System.currentTimeMillis());
+ } else if (job.getFactoryKey().equals(PushMediaSendJob.KEY) && mms.contains(PushMediaSendJob.getMessageId(data))) {
+ return job.withNextRunAttemptTime(System.currentTimeMillis());
+ } else if (job.getFactoryKey().equals(PushGroupSendJob.KEY) && mms.contains(PushGroupSendJob.getMessageId(data))) {
+ return job.withNextRunAttemptTime(System.currentTimeMillis());
+ } else {
+ return job;
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java
new file mode 100644
index 0000000000..5ad9351363
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofActivity.java
@@ -0,0 +1,144 @@
+package org.thoughtcrime.securesms.ratelimit;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.MenuItem;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.BuildConfig;
+import org.thoughtcrime.securesms.PassphraseRequiredActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
+import org.thoughtcrime.securesms.keyvalue.SignalStore;
+import org.thoughtcrime.securesms.util.DynamicTheme;
+import org.thoughtcrime.securesms.util.Util;
+import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
+import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
+import org.whispersystems.libsignal.util.Pair;
+import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
+
+import java.io.IOException;
+
+/**
+ * Asks the user to solve a reCAPTCHA. If successful, triggers resends of all relevant message jobs.
+ */
+public class RecaptchaProofActivity extends PassphraseRequiredActivity {
+ private static final String TAG = Log.tag(RecaptchaProofActivity.class);
+
+ private static final String RECAPTCHA_SCHEME = "signalcaptcha://";
+
+ private final DynamicTheme dynamicTheme = new DynamicTheme();
+
+ public static @NonNull Intent getIntent(@NonNull Context context) {
+ return new Intent(context, RecaptchaProofActivity.class);
+ }
+
+ @Override
+ protected void onPreCreate() {
+ dynamicTheme.onCreate(this);
+ }
+
+ @Override
+ @SuppressLint("SetJavaScriptEnabled")
+ protected void onCreate(Bundle savedInstanceState, boolean ready) {
+ super.onCreate(savedInstanceState, ready);
+
+ setContentView(R.layout.recaptcha_activity);
+
+ requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ requireSupportActionBar().setTitle(R.string.RecaptchaProofActivity_complete_verification);
+
+ WebView webView = findViewById(R.id.recaptcha_webview);
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.clearCache(true);
+ webView.setBackgroundColor(Color.TRANSPARENT);
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ if (url != null && url.startsWith(RECAPTCHA_SCHEME)) {
+ handleToken(url.substring(RECAPTCHA_SCHEME.length()));
+ return true;
+ }
+ return false;
+ }
+ });
+
+ webView.loadUrl(BuildConfig.RECAPTCHA_PROOF_URL);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ dynamicTheme.onResume(this);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void handleToken(@NonNull String token) {
+ SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(this, 1000, 500);
+ SimpleTask.run(() -> {
+ String challenge = SignalStore.rateLimit().getChallenge();
+ if (Util.isEmpty(challenge)) {
+ Log.w(TAG, "No challenge available?");
+ return new TokenResult(true, false);
+ }
+
+ try {
+ for (int i = 0; i < 3; i++) {
+ try {
+ ApplicationDependencies.getSignalServiceAccountManager().submitRateLimitRecaptchaChallenge(challenge, token);
+ RateLimitUtil.retryAllRateLimitedMessages(this);
+ Log.i(TAG, "Successfully completed reCAPTCHA.");
+ return new TokenResult(true, true);
+ } catch (PushNetworkException e) {
+ Log.w(TAG, "Network error during submission. Retrying.", e);
+ }
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Terminal failure during submission. Will clear state. May get a 428 later.", e);
+ return new TokenResult(true, false);
+ }
+
+ return new TokenResult(false, false);
+ }, result -> {
+ dialog.dismiss();
+
+ if (result.clearState) {
+ Log.i(TAG, "Considering the response sufficient to clear the slate.");
+ SignalStore.rateLimit().onProofAccepted();
+ }
+
+ if (!result.success) {
+ Log.w(TAG, "Response was not a true success.");
+ Toast.makeText(this, R.string.RecaptchaProofActivity_failed_to_submit, Toast.LENGTH_LONG).show();
+ }
+
+ finish();
+ });
+ }
+
+ private static final class TokenResult {
+ final boolean clearState;
+ final boolean success;
+
+ private TokenResult(boolean clearState, boolean success) {
+ this.clearState = clearState;
+ this.success = success;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java
new file mode 100644
index 0000000000..13ede18100
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaProofBottomSheetFragment.java
@@ -0,0 +1,57 @@
+package org.thoughtcrime.securesms.ratelimit;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+import org.signal.core.util.logging.Log;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.util.BottomSheetUtil;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+
+/**
+ * A bottom sheet to be shown when we need to prompt the user to fill out a reCAPTCHA.
+ */
+public final class RecaptchaProofBottomSheetFragment extends BottomSheetDialogFragment {
+
+ private static final String TAG = Log.tag(RecaptchaProofBottomSheetFragment.class);
+
+ public static void show(@NonNull FragmentManager manager) {
+ new RecaptchaProofBottomSheetFragment().show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ setStyle(DialogFragment.STYLE_NORMAL, R.style.Signal_DayNight_BottomSheet_Rounded);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.recaptcha_required_bottom_sheet, container, false);
+
+ view.findViewById(R.id.recaptcha_sheet_ok_button).setOnClickListener(v -> {
+ dismissAllowingStateLoss();
+ startActivity(RecaptchaProofActivity.getIntent(requireContext()));
+ });
+
+ return view;
+ }
+
+ @Override
+ public void show(@NonNull FragmentManager manager, @Nullable String tag) {
+ if (manager.findFragmentByTag(tag) == null) {
+ BottomSheetUtil.show(manager, tag, this);
+ } else {
+ Log.i(TAG, "Ignoring repeat show.");
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java
new file mode 100644
index 0000000000..ae71fc1d08
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/ratelimit/RecaptchaRequiredEvent.java
@@ -0,0 +1,4 @@
+package org.thoughtcrime.securesms.ratelimit;
+
+public final class RecaptchaRequiredEvent {
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java
index 4a99b2a146..00ca6a5830 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java
@@ -91,7 +91,7 @@ public final class PushChallengeRequest {
eventBus.register(this);
try {
- accountManager.requestPushChallenge(fcmToken, e164number);
+ accountManager.requestRegistrationPushChallenge(fcmToken, e164number);
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java
index 626bbafcdf..fe83f525ad 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/transport/RetryLaterException.java
@@ -1,9 +1,30 @@
package org.thoughtcrime.securesms.transport;
public class RetryLaterException extends Exception {
- public RetryLaterException() {}
+
+ private final long backoff;
+
+ public RetryLaterException() {
+ this(null, -1);
+ }
+
+ public RetryLaterException(long backoff) {
+ this(null, backoff);
+ }
public RetryLaterException(Exception e) {
+ this(e, -1);
+ }
+
+ public RetryLaterException(Exception e, long backoff) {
super(e);
+ this.backoff = backoff;
+ }
+
+ /**
+ * @return The amount of time to wait before retrying again, or -1 if none is specified.
+ */
+ public long getBackoff() {
+ return backoff;
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index 66b1617125..2513281682 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.util;
+import android.app.Application;
import android.os.Build;
import android.text.TextUtils;
diff --git a/app/src/main/res/layout/recaptcha_activity.xml b/app/src/main/res/layout/recaptcha_activity.xml
new file mode 100644
index 0000000000..64a860c6ed
--- /dev/null
+++ b/app/src/main/res/layout/recaptcha_activity.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml b/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml
new file mode 100644
index 0000000000..3aaa5b06bc
--- /dev/null
+++ b/app/src/main/res/layout/recaptcha_required_bottom_sheet.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7bf1628169..7b0427720f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -202,6 +202,7 @@
Send failed
Received key exchange message, tap to process.
%1$s has left the group.
+ Send paused
Send failed, tap for unsecured fallback
Fallback to unencrypted SMS?
Fallback to unencrypted MMS?
@@ -1354,6 +1355,11 @@
You
+
+ Verify to continue messaging
+ To help prevent spam on Signal, please complete verification.
+ After verifying, you can continue messaging. Any paused messages will automatically be sent.
+
Block
Unblock
@@ -1422,6 +1428,9 @@
Use proxy
Successfully connected to proxy.
+
+ Failed to submit
+ Complete verification
Select your country
@@ -1694,6 +1703,8 @@
Message delivery failed.
Failed to deliver message.
Error delivering message.
+ Message delivery paused.
+ Verify to continue messaging on Signal.
Mark all as read
Mark read
Turn off these notifications
diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java b/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java
index 9a9d2559d2..47cba6907a 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java
+++ b/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java
@@ -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 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 challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L);
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
index 5e7b44a2bd..75ddb33446 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java
@@ -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);
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java
index 2c1f04eb13..cc133bfc33 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessagePipe.java
@@ -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) {
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java
index 7d11e4fec0..88212129b2 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java
@@ -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);
}
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java
index 779d17531f..35212cfc78 100644
--- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SendMessageResult.java
@@ -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 {
diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ProofRequiredException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ProofRequiredException.java
new file mode 100644
index 0000000000..4c4f3f558a
--- /dev/null
+++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ProofRequiredException.java
@@ -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