From f19033a7a2073c09b11d6c613cb0fcff00bfef36 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 24 Jun 2021 11:22:20 -0400 Subject: [PATCH] Implement the message send log for sender key retries. --- .../securesms/ApplicationContext.java | 1 + .../ConversationListFragment.java | 1 + .../securesms/crypto/SessionUtil.java | 10 + .../securesms/database/DatabaseFactory.java | 17 +- .../securesms/database/MessageDatabase.java | 1 + .../database/MessageSendLogDatabase.kt | 265 ++++++++++++++++++ .../securesms/database/MmsDatabase.java | 56 ++-- .../securesms/database/SmsDatabase.java | 53 +++- .../database/SqlCipherDatabaseHook.java | 3 +- .../securesms/database/ThreadDatabase.java | 1 + .../database/helpers/SQLCipherOpenHelper.java | 31 +- .../database/model/MessageLogEntry.kt | 21 ++ .../securesms/jobmanager/Data.java | 8 +- .../securesms/jobs/JobManagerFactories.java | 20 +- .../securesms/jobs/NullMessageSendJob.java | 98 +++++++ .../securesms/jobs/PushGroupSendJob.java | 9 +- .../jobs/PushGroupSilentUpdateSendJob.java | 2 +- .../securesms/jobs/PushMediaSendJob.java | 8 +- .../securesms/jobs/PushTextSendJob.java | 8 +- .../securesms/jobs/ReactionSendJob.java | 19 +- .../securesms/jobs/RemoteDeleteSendJob.java | 18 +- .../securesms/jobs/ResendMessageJob.java | 162 +++++++++++ .../securesms/messages/GroupSendUtil.java | 125 ++++++++- .../messages/IncomingMessageProcessor.java | 6 +- .../messages/MessageContentProcessor.java | 167 ++++++++--- .../api/SignalServiceMessageSender.java | 71 +++-- .../signalservice/api/crypto/ContentHint.java | 2 +- .../api/crypto/EnvelopeContent.java | 33 ++- .../api/messages/SendMessageResult.java | 29 +- .../internal/push/OutgoingPushMessage.java | 4 + .../push/OutgoingPushMessageList.java | 7 + 31 files changed, 1077 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/MessageLogEntry.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index c207450264..1cef1f1181 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -163,6 +163,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this))) .addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this)) .addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary) + .addPostRender(() -> DatabaseFactory.getMessageLogDatabase(this).trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge())) .execute(); Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 3025b82e6b..6279620331 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -91,6 +91,7 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.conversation.ConversationFragment; import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.search.SearchResult; import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments; diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java index f8d125c25f..1702b6c3e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/SessionUtil.java @@ -8,6 +8,8 @@ import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.SignalProtocolAddress; +import org.whispersystems.libsignal.ecc.ECPublicKey; +import org.whispersystems.libsignal.state.SessionRecord; import org.whispersystems.libsignal.state.SessionStore; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -32,4 +34,12 @@ public class SessionUtil { public static void archiveSession(Context context, RecipientId recipientId, int deviceId) { new TextSecureSessionStore(context).archiveSession(recipientId, deviceId); } + + public static boolean ratchetKeyMatches(@NonNull Context context, @NonNull Recipient recipient, int deviceId, @NonNull ECPublicKey ratchetKey) { + TextSecureSessionStore sessionStore = new TextSecureSessionStore(context); + SignalProtocolAddress address = new SignalProtocolAddress(recipient.resolve().requireServiceId(), deviceId); + SessionRecord session = sessionStore.loadSession(address); + + return session.currentRatchetKeyMatches(ratchetKey); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 302e1c49ad..2fa8a94ce9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; -import org.thoughtcrime.securesms.database.model.PendingRetryReceiptModel; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.util.SqlUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -70,6 +69,7 @@ public class DatabaseFactory { private final PaymentDatabase paymentDatabase; private final ChatColorsDatabase chatColorsDatabase; private final EmojiSearchDatabase emojiSearchDatabase; + private final MessageSendLogDatabase messageSendLogDatabase; public static DatabaseFactory getInstance(Context context) { if (instance == null) { @@ -188,16 +188,20 @@ public class DatabaseFactory { return getInstance(context).paymentDatabase; } + public static ChatColorsDatabase getChatColorsDatabase(Context context) { + return getInstance(context).chatColorsDatabase; + } + public static EmojiSearchDatabase getEmojiSearchDatabase(Context context) { return getInstance(context).emojiSearchDatabase; } - public static SQLiteDatabase getBackupDatabase(Context context) { - return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase(); + public static MessageSendLogDatabase getMessageLogDatabase(Context context) { + return getInstance(context).messageSendLogDatabase; } - public static ChatColorsDatabase getChatColorsDatabase(Context context) { - return getInstance(context).chatColorsDatabase; + public static SQLiteDatabase getBackupDatabase(Context context) { + return getInstance(context).databaseHelper.getReadableDatabase().getSqlCipherDatabase(); } public static void upgradeRestored(Context context, SQLiteDatabase database){ @@ -253,7 +257,8 @@ public class DatabaseFactory { this.mentionDatabase = new MentionDatabase(context, databaseHelper); this.paymentDatabase = new PaymentDatabase(context, databaseHelper); this.chatColorsDatabase = new ChatColorsDatabase(context, databaseHelper); - this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper); + this.emojiSearchDatabase = new EmojiSearchDatabase(context, databaseHelper); + this.messageSendLogDatabase = new MessageSendLogDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 77c44e2b4e..99004c497e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -86,6 +86,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract Cursor getMessageCursor(long messageId); public abstract OutgoingMediaMessage getOutgoingMessage(long messageId) throws MmsException, NoSuchMessageException; public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException; + public abstract @Nullable MessageRecord getMessageRecordOrNull(long messageId); public abstract Cursor getVerboseMessageCursor(long messageId); public abstract boolean hasReceivedAnyCallsSince(long threadId, long timestamp); public abstract @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogDatabase.kt new file mode 100644 index 0000000000..914329ca2b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageSendLogDatabase.kt @@ -0,0 +1,265 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import org.thoughtcrime.securesms.database.model.MessageLogEntry +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.CursorUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.SqlUtil +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.api.messages.SendMessageResult +import org.whispersystems.signalservice.internal.push.SignalServiceProtos +import java.util.UUID + +/** + * Stores a 24-hr buffer of all outgoing messages. Used for the retry logic required for sender key. + * + * General note: This class is actually two tables -- one to store the entry, and another to store all the devices that were sent it. + * + * The general lifecycle of entries in the store goes something like this: + * - Upon sending a message, throw an entry in the 'message table' and throw an entry for each recipient you sent it to in the 'recipient table' + * - Whenever you get a delivery receipt, delete the entries in the 'recipient table' + * - Whenever there's no more records in the 'recipient table' for a given message, delete the entry in the 'message table' + * - Whenever you delete a message, delete the entry in the 'message table' + * - Whenever you read an entry from the table, first trim off all the entries that are too old + * + * Because of all of this, you can be sure that if an entry is in this store, it's safe to resend to someone upon request + * + * Worth noting that we use triggers + foreign keys to make sure entries in this table are properly cleaned up. Triggers for when you delete a message, and + * a cascading delete foreign key between these two tables. + */ +class MessageSendLogDatabase constructor(context: Context?, databaseHelper: SQLCipherOpenHelper?) : Database(context, databaseHelper) { + + companion object { + @JvmField + val CREATE_TABLE: Array = arrayOf(MessageTable.CREATE_TABLE, RecipientTable.CREATE_TABLE) + + @JvmField + val CREATE_INDEXES: Array = MessageTable.CREATE_INDEXES + RecipientTable.CREATE_INDEXES + + @JvmField + val CREATE_TRIGGERS: Array = MessageTable.CREATE_TRIGGERS + } + + private object MessageTable { + const val TABLE_NAME = "message_send_log" + + const val ID = "_id" + const val DATE_SENT = "date_sent" + const val CONTENT = "content" + const val RELATED_MESSAGE_ID = "related_message_id" + const val IS_RELATED_MESSAGE_MMS = "is_related_message_mms" + const val CONTENT_HINT = "content_hint" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $DATE_SENT INTEGER NOT NULL, + $CONTENT BLOB NOT NULL, + $RELATED_MESSAGE_ID INTEGER DEFAULT -1, + $IS_RELATED_MESSAGE_MMS INTEGER DEFAULT 0, + $CONTENT_HINT INTEGER NOT NULL + ) + """ + + @JvmField + val CREATE_INDEXES = arrayOf( + "CREATE INDEX message_log_date_sent_index ON $TABLE_NAME ($DATE_SENT)", + "CREATE INDEX message_log_related_message_index ON $TABLE_NAME ($RELATED_MESSAGE_ID, $IS_RELATED_MESSAGE_MMS)" + ) + + @JvmField + val CREATE_TRIGGERS = arrayOf( + """ + CREATE TRIGGER msl_sms_delete AFTER DELETE ON ${SmsDatabase.TABLE_NAME} + BEGIN + DELETE FROM $TABLE_NAME WHERE $RELATED_MESSAGE_ID = old.${SmsDatabase.ID} AND $IS_RELATED_MESSAGE_MMS = 0; + END + """, + """ + CREATE TRIGGER msl_mms_delete AFTER DELETE ON ${MmsDatabase.TABLE_NAME} + BEGIN + DELETE FROM $TABLE_NAME WHERE $RELATED_MESSAGE_ID = old.${MmsDatabase.ID} AND $IS_RELATED_MESSAGE_MMS = 1; + END + """ + ) + } + + private object RecipientTable { + const val TABLE_NAME = "message_send_log_recipients" + + const val ID = "_id" + const val MESSAGE_LOG_ID = "message_send_log_id" + const val RECIPIENT_ID = "recipient_id" + const val DEVICE = "device" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $MESSAGE_LOG_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE, + $RECIPIENT_ID INTEGER NOT NULL, + $DEVICE INTEGER NOT NULL + ) + """ + + val CREATE_INDEXES = arrayOf( + "CREATE INDEX message_send_log_recipients_recipient_index ON $TABLE_NAME ($RECIPIENT_ID, $DEVICE)" + ) + } + + fun insertIfPossible(recipientId: RecipientId, sentTimestamp: Long, sendMessageResult: SendMessageResult, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) { + if (!FeatureFlags.senderKey()) return + + if (sendMessageResult.isSuccess && sendMessageResult.success.content.isPresent) { + val recipientDevice = listOf(RecipientDevice(recipientId, sendMessageResult.success.devices)) + insert(recipientDevice, sentTimestamp, sendMessageResult.success.content.get(), contentHint, relatedMessageId, isRelatedMessageMms) + } + } + + fun insertIfPossible(sentTimestamp: Long, possibleRecipients: List, results: List, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) { + if (!FeatureFlags.senderKey()) return + + val recipientsByUuid: Map = possibleRecipients.filter(Recipient::hasUuid).associateBy(Recipient::requireUuid, { it }) + val recipientsByE164: Map = possibleRecipients.filter(Recipient::hasE164).associateBy(Recipient::requireE164, { it }) + + val recipientDevices: List = results + .filter { it.isSuccess && it.success.content.isPresent } + .map { result -> + val recipient: Recipient = + if (result.address.uuid.isPresent) { + recipientsByUuid[result.address.uuid.get()]!! + } else { + recipientsByE164[result.address.number.get()]!! + } + + RecipientDevice(recipient.id, result.success.devices) + } + + val content: SignalServiceProtos.Content = results.first { it.isSuccess && it.success.content.isPresent }.success.content.get() + + insert(recipientDevices, sentTimestamp, content, contentHint, relatedMessageId, isRelatedMessageMms) + } + + private fun insert(recipients: List, dateSent: Long, content: SignalServiceProtos.Content, contentHint: ContentHint, relatedMessageId: Long, isRelatedMessageMms: Boolean) { + val db = databaseHelper.writableDatabase + + db.beginTransaction() + try { + val logValues = ContentValues().apply { + put(MessageTable.DATE_SENT, dateSent) + put(MessageTable.CONTENT, content.toByteArray()) + put(MessageTable.CONTENT_HINT, contentHint.type) + put(MessageTable.RELATED_MESSAGE_ID, relatedMessageId) + put(MessageTable.IS_RELATED_MESSAGE_MMS, if (isRelatedMessageMms) 1 else 0) + } + + val messageLogId: Long = db.insert(MessageTable.TABLE_NAME, null, logValues) + + recipients.forEach { recipientDevice -> + recipientDevice.devices.forEach { device -> + val recipientValues = ContentValues() + recipientValues.put(RecipientTable.MESSAGE_LOG_ID, messageLogId) + recipientValues.put(RecipientTable.RECIPIENT_ID, recipientDevice.recipientId.serialize()) + recipientValues.put(RecipientTable.DEVICE, device) + + db.insert(RecipientTable.TABLE_NAME, null, recipientValues) + } + } + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun getLogEntry(recipientId: RecipientId, device: Int, dateSent: Long): MessageLogEntry? { + if (!FeatureFlags.senderKey()) return null + + trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()) + + val db = databaseHelper.readableDatabase + val table = "${MessageTable.TABLE_NAME} LEFT JOIN ${RecipientTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_LOG_ID}" + val query = "${MessageTable.DATE_SENT} = ? AND ${RecipientTable.RECIPIENT_ID} = ? AND ${RecipientTable.DEVICE} = ?" + val args = SqlUtil.buildArgs(dateSent, recipientId, device) + + db.query(table, null, query, args, null, null, null).use { cursor -> + if (cursor.moveToFirst()) { + return MessageLogEntry( + recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.RECIPIENT_ID)), + dateSent = CursorUtil.requireLong(cursor, MessageTable.DATE_SENT), + content = SignalServiceProtos.Content.parseFrom(CursorUtil.requireBlob(cursor, MessageTable.CONTENT)), + contentHint = ContentHint.fromType(CursorUtil.requireInt(cursor, MessageTable.CONTENT_HINT)), + relatedMessageId = CursorUtil.requireLong(cursor, MessageTable.RELATED_MESSAGE_ID), + isRelatedMessageMms = CursorUtil.requireBoolean(cursor, MessageTable.IS_RELATED_MESSAGE_MMS) + ) + } + } + + return null + } + + fun deleteAllRelatedToMessage(messageId: Long, mms: Boolean) { + if (!FeatureFlags.senderKey()) return + + val db = databaseHelper.writableDatabase + val query = "${MessageTable.RELATED_MESSAGE_ID} = ? AND ${MessageTable.IS_RELATED_MESSAGE_MMS} = ?" + val args = SqlUtil.buildArgs(messageId, if (mms) 1 else 0) + + db.delete(MessageTable.TABLE_NAME, query, args) + } + + fun deleteEntryForRecipient(dateSent: Long, recipientId: RecipientId, device: Int) { + if (!FeatureFlags.senderKey()) return + + deleteEntriesForRecipient(listOf(dateSent), recipientId, device) + } + + fun deleteEntriesForRecipient(dateSent: List, recipientId: RecipientId, device: Int) { + if (!FeatureFlags.senderKey()) return + + val db = databaseHelper.writableDatabase + + db.beginTransaction() + try { + val query = """ + ${RecipientTable.RECIPIENT_ID} = ? AND + ${RecipientTable.DEVICE} = ? AND + ${RecipientTable.MESSAGE_LOG_ID} IN ( + SELECT ${MessageTable.ID} + FROM ${MessageTable.TABLE_NAME} + WHERE ${MessageTable.DATE_SENT} IN (${dateSent.joinToString(",")}) + )""" + val args = SqlUtil.buildArgs(recipientId, device) + + db.delete(RecipientTable.TABLE_NAME, query, args) + + val cleanQuery = "${MessageTable.ID} NOT IN (SELECT ${RecipientTable.MESSAGE_LOG_ID} FROM ${RecipientTable.TABLE_NAME})" + db.delete(MessageTable.TABLE_NAME, cleanQuery, null) + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun deleteAll() { + if (!FeatureFlags.senderKey()) return + + databaseHelper.writableDatabase.delete(MessageTable.TABLE_NAME, null, null) + } + + fun trimOldMessages(currentTime: Long, maxAge: Long) { + if (!FeatureFlags.senderKey()) return + + val db = databaseHelper.writableDatabase + val query = "${MessageTable.DATE_SENT} < ?" + val args = SqlUtil.buildArgs(currentTime - maxAge) + + db.delete(MessageTable.TABLE_NAME, query, args) + } + + private data class RecipientDevice(val recipientId: RecipientId, val devices: List) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 26af531045..7807a29477 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -768,6 +768,13 @@ public class MmsDatabase extends MessageDatabase { } } + @Override + public @Nullable MessageRecord getMessageRecordOrNull(long messageId) { + try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) { + return new Reader(cursor).getNext(); + } + } + @Override public Reader getMessages(Collection messageIds) { String ids = TextUtils.join(",", messageIds); @@ -854,23 +861,32 @@ public class MmsDatabase extends MessageDatabase { public void markAsRemoteDelete(long messageId) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(REMOTE_DELETED, 1); - values.putNull(BODY); - values.putNull(QUOTE_BODY); - values.putNull(QUOTE_AUTHOR); - values.putNull(QUOTE_ATTACHMENT); - values.putNull(QUOTE_ID); - values.putNull(LINK_PREVIEWS); - values.putNull(SHARED_CONTACTS); - values.putNull(REACTIONS); - db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(messageId) }); + long threadId; - DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentsForMessage(messageId); - DatabaseFactory.getMentionDatabase(context).deleteMentionsForMessage(messageId); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(); + values.put(REMOTE_DELETED, 1); + values.putNull(BODY); + values.putNull(QUOTE_BODY); + values.putNull(QUOTE_AUTHOR); + values.putNull(QUOTE_ATTACHMENT); + values.putNull(QUOTE_ID); + values.putNull(LINK_PREVIEWS); + values.putNull(SHARED_CONTACTS); + values.putNull(REACTIONS); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(messageId) }); - long threadId = getThreadIdForMessage(messageId); - DatabaseFactory.getThreadDatabase(context).update(threadId, false); + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentsForMessage(messageId); + DatabaseFactory.getMentionDatabase(context).deleteMentionsForMessage(messageId); + DatabaseFactory.getMessageLogDatabase(context).deleteAllRelatedToMessage(messageId, true); + + threadId = getThreadIdForMessage(messageId); + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } notifyConversationListeners(threadId); } @@ -1661,6 +1677,7 @@ public class MmsDatabase extends MessageDatabase { SQLiteDatabase database = databaseHelper.getWritableDatabase(); database.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); + boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false); notifyConversationListeners(threadId); notifyStickerListeners(); @@ -1773,7 +1790,6 @@ public class MmsDatabase extends MessageDatabase { SQLiteDatabase db = databaseHelper.getWritableDatabase(); String where = ""; - Cursor cursor = null; for (long threadId : threadIds) { where += THREAD_ID + " = '" + threadId + "' OR "; @@ -1781,16 +1797,10 @@ public class MmsDatabase extends MessageDatabase { where = where.substring(0, where.length() - 4); - try { - cursor = db.query(TABLE_NAME, new String[] {ID}, where, null, null, null, null); - + try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID}, where, null, null, null, null)) { while (cursor != null && cursor.moveToNext()) { deleteMessage(cursor.getLong(0)); } - - } finally { - if (cursor != null) - cursor.close(); } } 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 5c336238b2..e4b4d1e3bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -414,15 +414,25 @@ public class SmsDatabase extends MessageDatabase { public void markAsRemoteDelete(long id) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); - ContentValues values = new ContentValues(); - values.put(REMOTE_DELETED, 1); - values.putNull(BODY); - values.putNull(REACTIONS); - db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) }); + long threadId; - long threadId = getThreadIdForMessage(id); + db.beginTransaction(); + try { + ContentValues values = new ContentValues(); + values.put(REMOTE_DELETED, 1); + values.putNull(BODY); + values.putNull(REACTIONS); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) }); - DatabaseFactory.getThreadDatabase(context).update(threadId, false); + threadId = getThreadIdForMessage(id); + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + DatabaseFactory.getMessageLogDatabase(context).deleteAllRelatedToMessage(id, false); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } notifyConversationListeners(threadId); } @@ -1299,12 +1309,23 @@ public class SmsDatabase extends MessageDatabase { public boolean deleteMessage(long messageId) { Log.d(TAG, "deleteMessage(" + messageId + ")"); - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - long threadId = getThreadIdForMessage(messageId); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); - db.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); + long threadId; + boolean threadDeleted; - boolean threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false, true); + db.beginTransaction(); + try { + threadId = getThreadIdForMessage(messageId); + + db.delete(TABLE_NAME, ID_WHERE, new String[] { messageId + "" }); + + threadDeleted = DatabaseFactory.getThreadDatabase(context).update(threadId, false, true); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } notifyConversationListeners(threadId); return threadDeleted; @@ -1320,6 +1341,15 @@ public class SmsDatabase extends MessageDatabase { return getSmsMessage(messageId); } + @Override + public @Nullable MessageRecord getMessageRecordOrNull(long messageId) { + try { + return getSmsMessage(messageId); + } catch (NoSuchMessageException e) { + return null; + } + } + private boolean isDuplicate(IncomingTextMessage message, long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String query = DATE_SENT + " = ? AND " + RECIPIENT_ID + " = ? AND " + THREAD_ID + " = ?"; @@ -1334,6 +1364,7 @@ public class SmsDatabase extends MessageDatabase { void deleteThread(long threadId) { Log.d(TAG, "deleteThread(" + threadId + ")"); SQLiteDatabase db = databaseHelper.getWritableDatabase(); + db.delete(TABLE_NAME, THREAD_ID + " = ?", new String[] {threadId+""}); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java b/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java index 6329c8807a..e0f8723ead 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SqlCipherDatabaseHook.java @@ -6,7 +6,7 @@ import net.sqlcipher.database.SQLiteDatabaseHook; /** * Standard hook for setting common SQLCipher PRAGMAs. */ -public final class SqlCipherDatabaseHook implements SQLiteDatabaseHook { +public class SqlCipherDatabaseHook implements SQLiteDatabaseHook { @Override public void preKey(SQLiteDatabase db) { @@ -20,5 +20,6 @@ public final class SqlCipherDatabaseHook implements SQLiteDatabaseHook { db.rawExecSQL("PRAGMA cipher_memory_security = OFF;"); db.rawExecSQL("PRAGMA kdf_iter = '1';"); db.rawExecSQL("PRAGMA cipher_page_size = 4096;"); + db.setForeignKeyConstraintsEnabled(true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 6a145074f5..6058d6147b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -960,6 +960,7 @@ public class ThreadDatabase extends Database { db.beginTransaction(); try { + DatabaseFactory.getMessageLogDatabase(context).deleteAll(); DatabaseFactory.getSmsDatabase(context).deleteAllThreads(); DatabaseFactory.getMmsDatabase(context).deleteAllThreads(); DatabaseFactory.getDraftDatabase(context).clearAllDrafts(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 802672dc9b..d749cce45b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.MentionDatabase; +import org.thoughtcrime.securesms.database.MessageSendLogDatabase; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase; import org.thoughtcrime.securesms.database.PaymentDatabase; @@ -199,8 +200,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab private static final int EMOJI_SEARCH = 102; private static final int SENDER_KEY = 103; private static final int MESSAGE_DUPE_INDEX = 104; + private static final int MESSAGE_LOG = 105; - private static final int DATABASE_VERSION = 104; + private static final int DATABASE_VERSION = 105; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -239,6 +241,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL(EmojiSearchDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); + executeStatements(db, MessageSendLogDatabase.CREATE_TABLE); executeStatements(db, RecipientDatabase.CREATE_INDEXS); executeStatements(db, SmsDatabase.CREATE_INDEXS); @@ -252,6 +255,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab executeStatements(db, UnknownStorageIdDatabase.CREATE_INDEXES); executeStatements(db, MentionDatabase.CREATE_INDEXES); executeStatements(db, PaymentDatabase.CREATE_INDEXES); + executeStatements(db, MessageSendLogDatabase.CREATE_INDEXES); + + executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS); if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) { ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context); @@ -1569,6 +1575,29 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab db.execSQL("CREATE INDEX mms_date_sent_index on mms(date, address, thread_id)"); } + if (oldVersion < MESSAGE_LOG) { + db.execSQL("CREATE TABLE message_send_log (_id INTEGER PRIMARY KEY, " + + "date_sent INTEGER NOT NULL, " + + "content BLOB NOT NULL, " + + "related_message_id INTEGER DEFAULT -1, " + + "is_related_message_mms INTEGER DEFAULT 0, " + + "content_hint INTEGER NOT NULL, " + + "group_id BLOB DEFAULT NULL)"); + + db.execSQL("CREATE INDEX message_log_date_sent_index ON message_send_log (date_sent)"); + db.execSQL("CREATE INDEX message_log_related_message_index ON message_send_log (related_message_id, is_related_message_mms)"); + + db.execSQL("CREATE TRIGGER msl_sms_delete AFTER DELETE ON sms BEGIN DELETE FROM message_send_log WHERE related_message_id = old._id AND is_related_message_mms = 0; END"); + db.execSQL("CREATE TRIGGER msl_mms_delete AFTER DELETE ON mms BEGIN DELETE FROM message_send_log WHERE related_message_id = old._id AND is_related_message_mms = 1; END"); + + db.execSQL("CREATE TABLE message_send_log_recipients (_id INTEGER PRIMARY KEY, " + + "message_send_log_id INTEGER NOT NULL REFERENCES message_send_log (_id) ON DELETE CASCADE, " + + "recipient_id INTEGER NOT NULL, " + + "device INTEGER NOT NULL)"); + + db.execSQL("CREATE INDEX message_send_log_recipients_recipient_index ON message_send_log_recipients (recipient_id, device)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageLogEntry.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageLogEntry.kt new file mode 100644 index 0000000000..34b8ec4f1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageLogEntry.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.database.model + +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.internal.push.SignalServiceProtos + +/** + * Model class for reading from the [org.thoughtcrime.securesms.database.MessageSendLogDatabase]. + */ +data class MessageLogEntry( + val recipientId: RecipientId, + val dateSent: Long, + val content: SignalServiceProtos.Content, + val contentHint: ContentHint, + val relatedMessageId: Long, + val isRelatedMessageMms: Boolean, +) { + val hasRelatedMessage: Boolean + @JvmName("hasRelatedMessage") + get() = relatedMessageId > 0 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java index 432d9bdf40..207dedbf43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/Data.java @@ -68,8 +68,8 @@ public class Data { } public byte[] getStringAsBlob(@NonNull String key) { - throwIfAbsent(strings, key); - return Base64.decodeOrThrow(strings.get(key)); + String raw = getString(key); + return raw != null ? Base64.decodeOrThrow(raw) : null; } public String getStringOrDefault(@NonNull String key, String defaultValue) { @@ -356,8 +356,8 @@ public class Data { return this; } - public Builder putBlobAsString(@NonNull String key, @NonNull byte[] value) { - String serialized = Base64.encodeBytes(value); + public Builder putBlobAsString(@NonNull String key, @Nullable byte[] value) { + String serialized = value != null ? Base64.encodeBytes(value) : null; strings.put(key, serialized); return this; } 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 ca78e9da13..e2b55756e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -87,10 +87,12 @@ public final class JobManagerFactories { put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory()); put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory()); + put(GroupV2UpdateSelfProfileKeyJob.KEY, new GroupV2UpdateSelfProfileKeyJob.Factory()); put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory()); put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory()); + put(MarkerJob.KEY, new MarkerJob.Factory()); put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); put(MmsSendJob.KEY, new MmsSendJob.Factory()); @@ -110,7 +112,13 @@ public final class JobManagerFactories { put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory()); put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory()); put(MultiDeviceViewedUpdateJob.KEY, new MultiDeviceViewedUpdateJob.Factory()); + put(NullMessageSendJob.KEY, new NullMessageSendJob.Factory()); + put(PaymentLedgerUpdateJob.KEY, new PaymentLedgerUpdateJob.Factory()); + put(PaymentNotificationSendJob.KEY, new PaymentNotificationSendJob.Factory()); + put(PaymentSendJob.KEY, new PaymentSendJob.Factory()); + put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory()); put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory()); + put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); put(PushDecryptMessageJob.KEY, new PushDecryptMessageJob.Factory()); put(PushDecryptDrainedJob.KEY, new PushDecryptDrainedJob.Factory()); put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); @@ -126,12 +134,12 @@ public final class JobManagerFactories { put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory()); put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory()); + put(ReportSpamJob.KEY, new ReportSpamJob.Factory()); put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); + put(ResendMessageJob.KEY, new ResendMessageJob.Factory()); put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory()); - put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory()); put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory()); - put(GroupV2UpdateSelfProfileKeyJob.KEY, new GroupV2UpdateSelfProfileKeyJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); @@ -148,19 +156,13 @@ public final class JobManagerFactories { put(SmsSentJob.KEY, new SmsSentJob.Factory()); put(StickerDownloadJob.KEY, new StickerDownloadJob.Factory()); put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory()); + put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); put(StorageForcePushJob.KEY, new StorageForcePushJob.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()); - put(MarkerJob.KEY, new MarkerJob.Factory()); - put(PaymentLedgerUpdateJob.KEY, new PaymentLedgerUpdateJob.Factory()); - put(PaymentNotificationSendJob.KEY, new PaymentNotificationSendJob.Factory()); - put(PaymentSendJob.KEY, new PaymentSendJob.Factory()); - put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory()); - put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); - put(ReportSpamJob.KEY, new ReportSpamJob.Factory()); // Migrations put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java new file mode 100644 index 0000000000..ad5b07331c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/NullMessageSendJob.java @@ -0,0 +1,98 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +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.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.util.concurrent.TimeUnit; + +/** + * Just sends an empty message to a target recipient. Only suitable for individuals, NOT groups. + */ +public class NullMessageSendJob extends BaseJob { + + public static final String KEY = "NullMessageSendJob"; + + private static final String TAG = Log.tag(NullMessageSendJob.class); + + private final RecipientId recipientId; + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + + public NullMessageSendJob(@NonNull RecipientId recipientId) { + this(recipientId, + new Parameters.Builder() + .setQueue(recipientId.toQueueKey()) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private NullMessageSendJob(@NonNull RecipientId recipientId, @NonNull Parameters parameters) { + super(parameters); + this.recipientId = recipientId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + Recipient recipient = Recipient.resolved(recipientId); + + if (recipient.isGroup()) { + Log.w(TAG, "Groups are not supported!"); + return; + } + + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient); + Optional unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient); + + try { + messageSender.sendNullMessage(address, unidentifiedAccess); + } catch (UntrustedIdentityException e) { + Log.w(TAG, "Unable to send null message."); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull NullMessageSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new NullMessageSendJob(RecipientId.from(data.getString(KEY_RECIPIENT_ID)), + parameters); + } + } +} 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 74f691bb62..008ccd47ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -39,7 +39,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.transport.UndeliverableMessageException; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; @@ -330,7 +329,7 @@ public final class PushGroupSendJob extends PushSendJob { .withExpiration(groupRecipient.getExpireMessages()) .asGroupMessage(group) .build(); - return GroupSendUtil.sendDataMessage(context, groupRecipient.requireGroupId().requireV2(), destinations, isRecipientUpdate, ContentHint.IMPLICIT, groupDataMessage); + return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), destinations, isRecipientUpdate, ContentHint.IMPLICIT, messageId, true, groupDataMessage); } else { MessageGroupContext.GroupV1Properties properties = groupMessage.requireGroupV1Properties(); @@ -372,9 +371,11 @@ public final class PushGroupSendJob extends PushSendJob { Log.i(TAG, JobLogger.format(this, "Beginning message send.")); if (groupRecipient.isPushV2Group()) { - return GroupSendUtil.sendDataMessage(context, groupRecipient.requireGroupId().requireV2(), destinations, isRecipientUpdate, ContentHint.RESENDABLE, groupMessage); + return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), destinations, isRecipientUpdate, ContentHint.RESENDABLE, messageId, true, groupMessage); } else { - return messageSender.sendDataMessage(addresses, unidentifiedAccess, isRecipientUpdate, ContentHint.RESENDABLE, groupMessage); + List results = messageSender.sendDataMessage(addresses, unidentifiedAccess, isRecipientUpdate, ContentHint.RESENDABLE, groupMessage); + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(groupMessage.getTimestamp(), destinations, results, ContentHint.RESENDABLE, messageId, true); + return results; } } } catch (ServerRejectedException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java index 6a3b82b957..b08c00dcc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java @@ -167,7 +167,7 @@ public final class PushGroupSilentUpdateSendJob extends BaseJob { .asGroupMessage(group) .build(); - List results = GroupSendUtil.sendDataMessage(context, groupId, destinations, false, ContentHint.IMPLICIT, groupDataMessage); + List results = GroupSendUtil.sendUnresendableDataMessage(context, groupId, destinations, false, ContentHint.IMPLICIT, groupDataMessage); return GroupSendJobHelper.getCompletedSends(context, results); } 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 8093638a16..8663cc6f8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushMediaSendJob.java @@ -35,6 +35,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Preview; @@ -222,10 +223,13 @@ public class PushMediaSendJob extends PushSendJob { Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, mediaMessage, syncAccess); - messageSender.sendSyncMessage(syncMessage, syncAccess); + SendMessageResult result = messageSender.sendSyncMessage(syncMessage, syncAccess); + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, messageId, true); return syncAccess.isPresent(); } else { - return messageSender.sendDataMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), ContentHint.RESENDABLE, mediaMessage).getSuccess().isUnidentified(); + SendMessageResult result = messageSender.sendDataMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), ContentHint.RESENDABLE, mediaMessage); + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, messageId, true); + return result.getSuccess().isUnidentified(); } } catch (UnregisteredUserException e) { warn(TAG, String.valueOf(message.getSentTimeMillis()), e); 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 d727117b43..700df41028 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushTextSendJob.java @@ -27,6 +27,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -174,10 +175,13 @@ public class PushTextSendJob extends PushSendJob { Optional syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); SignalServiceSyncMessage syncMessage = buildSelfSendSyncMessage(context, textSecureMessage, syncAccess); - messageSender.sendSyncMessage(syncMessage, syncAccess); + SendMessageResult result = messageSender.sendSyncMessage(syncMessage, syncAccess); + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(messageRecipient.getId(), message.getDateSent(), result, ContentHint.RESENDABLE, messageId, false); return syncAccess.isPresent(); } else { - return messageSender.sendDataMessage(address, unidentifiedAccess, ContentHint.RESENDABLE, textSecureMessage).getSuccess().isUnidentified(); + SendMessageResult result = messageSender.sendDataMessage(address, unidentifiedAccess, ContentHint.RESENDABLE, textSecureMessage); + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(messageRecipient.getId(), message.getDateSent(), result, ContentHint.RESENDABLE, messageId, false); + return result.getSuccess().isUnidentified(); } } catch (UnregisteredUserException e) { warn(TAG, "Failure", e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java index 11e318b10d..116b7521d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -31,7 +31,6 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; -import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; @@ -219,24 +218,28 @@ public class ReactionSendJob extends BaseJob { private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp) throws IOException, UntrustedIdentityException { - SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() - .withTimestamp(System.currentTimeMillis()) - .withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp)); + SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp)); if (conversationRecipient.isGroup()) { - GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()); } + SignalServiceDataMessage dataMessage = dataMessageBuilder.build(); + List results; if (conversationRecipient.isPushV2Group()) { - results = GroupSendUtil.sendDataMessage(context, conversationRecipient.requireGroupId().requireV2(), destinations, false, ContentHint.DEFAULT, dataMessage.build()); + results = GroupSendUtil.sendResendableDataMessage(context, conversationRecipient.requireGroupId().requireV2(), destinations, false, ContentHint.RESENDABLE, messageId, isMms, dataMessageBuilder.build()); } else { SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); - List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations);; + List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations); - results = messageSender.sendDataMessage(addresses, unidentifiedAccess, false, ContentHint.DEFAULT, dataMessage.build()); + results = messageSender.sendDataMessage(addresses, unidentifiedAccess, false, ContentHint.RESENDABLE, dataMessage); + + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(dataMessage.getTimestamp(), destinations, results, ContentHint.RESENDABLE, messageId, isMms); } return GroupSendJobHelper.getCompletedSends(context, results); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java index dfbd7d918f..8226a390f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -174,24 +174,28 @@ public class RemoteDeleteSendJob extends BaseJob { private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, long targetSentTimestamp) throws IOException, UntrustedIdentityException { - SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() - .withTimestamp(System.currentTimeMillis()) - .withRemoteDelete(new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp)); + SignalServiceDataMessage.Builder dataMessageBuilder = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withRemoteDelete(new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp)); if (conversationRecipient.isGroup()) { - GroupUtil.setDataMessageGroupContext(context, dataMessage, conversationRecipient.requireGroupId().requirePush()); + GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()); } + SignalServiceDataMessage dataMessage = dataMessageBuilder.build(); + List results; if (conversationRecipient.isPushV2Group()) { - results = GroupSendUtil.sendDataMessage(context, conversationRecipient.requireGroupId().requireV2(), destinations, false, ContentHint.DEFAULT, dataMessage.build()); + results = GroupSendUtil.sendResendableDataMessage(context, conversationRecipient.requireGroupId().requireV2(), destinations, false, ContentHint.RESENDABLE, messageId, isMms, dataMessage); } else { SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); List addresses = RecipientUtil.toSignalServiceAddressesFromResolved(context, destinations); List> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, destinations); - results = messageSender.sendDataMessage(addresses, unidentifiedAccess, false, ContentHint.DEFAULT, dataMessage.build()); + results = messageSender.sendDataMessage(addresses, unidentifiedAccess, false, ContentHint.RESENDABLE, dataMessage); + + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(dataMessage.getTimestamp(), destinations, results, ContentHint.RESENDABLE, messageId, isMms); } return GroupSendJobHelper.getCompletedSends(context, results); @@ -209,4 +213,4 @@ public class RemoteDeleteSendJob extends BaseJob { return new RemoteDeleteSendJob(messageId, isMms, recipients, initialRecipientCount, parameters); } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java new file mode 100644 index 0000000000..6a35e18b28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ResendMessageJob.java @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.whispersystems.libsignal.protocol.SenderKeyDistributionMessage; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.ContentHint; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.push.DistributionId; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content; + +import java.util.concurrent.TimeUnit; + +/** + * Resends a previously-sent message in response to receiving a retry receipt. + * + * Not for arbitrary retries due to network flakiness or something -- those should be handled within each individual job. + */ +public class ResendMessageJob extends BaseJob { + + public static final String KEY = "ResendMessageJob"; + + private final RecipientId recipientId; + private final long sentTimestamp; + private final Content content; + private final ContentHint contentHint; + private final GroupId.V2 groupId; + private final DistributionId distributionId; + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + private static final String KEY_SENT_TIMESTAMP = "sent_timestamp"; + private static final String KEY_CONTENT = "content"; + private static final String KEY_CONTENT_HINT = "content_hint"; + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_DISTRIBUTION_ID = "distribution_id"; + + // TODO [greyson] Maybe just pass in the MessageLogEntry? + public ResendMessageJob(@NonNull RecipientId recipientId, + long sentTimestamp, + @NonNull Content content, + @NonNull ContentHint contentHint, + @Nullable GroupId.V2 groupId, + @Nullable DistributionId distributionId) + { + this(recipientId, + sentTimestamp, + content, + contentHint, + groupId, + distributionId, + new Parameters.Builder().setQueue(recipientId.toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .addConstraint(NetworkConstraint.KEY) + .build()); + } + + private ResendMessageJob(@NonNull RecipientId recipientId, + long sentTimestamp, + @NonNull Content content, + @NonNull ContentHint contentHint, + @Nullable GroupId.V2 groupId, + @Nullable DistributionId distributionId, + @NonNull Parameters parameters) + { + super(parameters); + + this.recipientId = recipientId; + this.sentTimestamp = sentTimestamp; + this.content = content; + this.contentHint = contentHint; + this.groupId = groupId; + this.distributionId = distributionId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder() + .putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .putLong(KEY_SENT_TIMESTAMP, sentTimestamp) + .putBlobAsString(KEY_CONTENT, content.toByteArray()) + .putInt(KEY_CONTENT_HINT, contentHint.getType()) + .putBlobAsString(KEY_GROUP_ID, groupId != null ? groupId.getDecodedId() : null) + .putString(KEY_DISTRIBUTION_ID, distributionId != null ? distributionId.toString() : null) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + Recipient recipient = Recipient.resolved(recipientId); + SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient); + Optional access = UnidentifiedAccessUtil.getAccessFor(context, recipient); + Content contentToSend = content; + + if (distributionId != null) { + SenderKeyDistributionMessage senderKeyDistributionMessage = messageSender.getOrCreateNewGroupSession(distributionId); + ByteString distributionBytes = ByteString.copyFrom(senderKeyDistributionMessage.serialize()); + + contentToSend = contentToSend.toBuilder().setSenderKeyDistributionMessage(distributionBytes).build(); + } + + messageSender.resendContent(address, access, sentTimestamp, contentToSend, contentHint, Optional.fromNullable(groupId).transform(GroupId::getDecodedId)); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull ResendMessageJob create(@NonNull Parameters parameters, @NonNull Data data) { + Content content; + try { + content = Content.parseFrom(data.getStringAsBlob(KEY_CONTENT)); + } catch (InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + + byte[] rawGroupId = data.getStringAsBlob(KEY_GROUP_ID); + GroupId.V2 groupId = rawGroupId != null ? GroupId.pushOrThrow(rawGroupId).requireV2() : null; + + String rawDistributionId = data.getString(KEY_DISTRIBUTION_ID); + DistributionId distributionId = rawDistributionId != null ? DistributionId.from(rawDistributionId) : null; + + return new ResendMessageJob(RecipientId.from(data.getString(KEY_RECIPIENT_ID)), + data.getLong(KEY_SENT_TIMESTAMP), + content, + ContentHint.fromType(data.getInt(KEY_CONTENT_HINT)), + groupId, + distributionId, + parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index ca38af6bc7..7da5fef764 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -35,6 +35,7 @@ import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; @@ -58,18 +59,41 @@ public final class GroupSendUtil { * Handles all of the logic of sending to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of * {@link SendMessageResult}s just like we're used to. * + * Messages sent this way, if failed to be decrypted by the receiving party, can be requested to be resent. + * + * @param isRecipientUpdate True if you've already sent this message to some recipients in the past, otherwise false. + */ + public static List sendResendableDataMessage(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull List allTargets, + boolean isRecipientUpdate, + ContentHint contentHint, + long relatedMessageId, + boolean isRelatedMessageMms, + @NonNull SignalServiceDataMessage message) + throws IOException, UntrustedIdentityException + { + return sendMessage(context, groupId, allTargets, isRecipientUpdate, DataSendOperation.resendable(message, contentHint, relatedMessageId, isRelatedMessageMms), null); + } + + /** + * Handles all of the logic of sending to a group. Will do sender key sends and legacy 1:1 sends as-needed, and give you back a list of + * {@link SendMessageResult}s just like we're used to. + * + * Messages sent this way, if failed to be decrypted by the receiving party, can *not* be requested to be resent. + * * @param isRecipientUpdate True if you've already sent this message to some recipients in the past, otherwise false. */ @WorkerThread - public static List sendDataMessage(@NonNull Context context, - @NonNull GroupId.V2 groupId, - @NonNull List allTargets, - boolean isRecipientUpdate, - ContentHint contentHint, - @NonNull SignalServiceDataMessage message) + public static List sendUnresendableDataMessage(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull List allTargets, + boolean isRecipientUpdate, + ContentHint contentHint, + @NonNull SignalServiceDataMessage message) throws IOException, UntrustedIdentityException { - return sendMessage(context, groupId, allTargets, isRecipientUpdate, new DataSendOperation(message, contentHint), null); + return sendMessage(context, groupId, allTargets, isRecipientUpdate, DataSendOperation.unresendable(message, contentHint), null); } /** @@ -173,6 +197,9 @@ public final class GroupSendUtil { } if (cancelationSignal != null && cancelationSignal.isCanceled()) { + if (sendOperation.shouldIncludeInMessageLog()) { + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(sendOperation.getSentTimestamp(), allTargets, allResults, sendOperation.getContentHint(), sendOperation.getRelatedMessageId(), sendOperation.isRelatedMessageMms()); + } throw new CancelationException(); } @@ -191,6 +218,10 @@ public final class GroupSendUtil { Log.d(TAG, "Successfully using 1:1 to " + successCount + "/" + targets.size() + " legacy targets."); } + if (sendOperation.shouldIncludeInMessageLog()) { + DatabaseFactory.getMessageLogDatabase(context).insertIfPossible(sendOperation.getSentTimestamp(), allTargets, allResults, sendOperation.getContentHint(), sendOperation.getRelatedMessageId(), sendOperation.isRelatedMessageMms()); + } + return allResults; } @@ -209,15 +240,35 @@ public final class GroupSendUtil { boolean isRecipientUpdate, @Nullable CancelationSignal cancelationSignal) throws IOException, UntrustedIdentityException; + + @NonNull ContentHint getContentHint(); + long getSentTimestamp(); + boolean shouldIncludeInMessageLog(); + long getRelatedMessageId(); + boolean isRelatedMessageMms(); } private static class DataSendOperation implements SendOperation { private final SignalServiceDataMessage message; private final ContentHint contentHint; + private final long relatedMessageId; + private final boolean isRelatedMessageMms; + private final boolean resendable; - private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint) { - this.message = message; - this.contentHint = contentHint; + public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, long relatedMessageId, boolean isRelatedMessageMms) { + return new DataSendOperation(message, contentHint, true, relatedMessageId, isRelatedMessageMms); + } + + public static DataSendOperation unresendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint) { + return new DataSendOperation(message, contentHint, false, -1, false); + } + + private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, long relatedMessageId, boolean isRelatedMessageMms) { + this.message = message; + this.contentHint = contentHint; + this.resendable = resendable; + this.relatedMessageId = relatedMessageId; + this.isRelatedMessageMms = isRelatedMessageMms; } @Override @@ -241,6 +292,31 @@ public final class GroupSendUtil { { return messageSender.sendDataMessage(targets, access, isRecipientUpdate, contentHint, message); } + + @Override + public @NonNull ContentHint getContentHint() { + return contentHint; + } + + @Override + public long getSentTimestamp() { + return message.getTimestamp(); + } + + @Override + public boolean shouldIncludeInMessageLog() { + return resendable; + } + + @Override + public long getRelatedMessageId() { + return relatedMessageId; + } + + @Override + public boolean isRelatedMessageMms() { + return isRelatedMessageMms; + } } private static class TypingSendOperation implements SendOperation { @@ -260,7 +336,7 @@ public final class GroupSendUtil { throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException { messageSender.sendGroupTyping(distributionId, targets, access, message); - return targets.stream().map(a -> SendMessageResult.success(a, true, false, -1)).collect(Collectors.toList()); + return targets.stream().map(a -> SendMessageResult.success(a, Collections.emptyList(), true, false, -1, Optional.absent())).collect(Collectors.toList()); } @Override @@ -272,7 +348,32 @@ public final class GroupSendUtil { throws IOException { messageSender.sendTyping(targets, access, message, cancelationSignal); - return targets.stream().map(a -> SendMessageResult.success(a, true, false, -1)).collect(Collectors.toList()); + return targets.stream().map(a -> SendMessageResult.success(a, Collections.emptyList(), true, false, -1, Optional.absent())).collect(Collectors.toList()); + } + + @Override + public @NonNull ContentHint getContentHint() { + return ContentHint.IMPLICIT; + } + + @Override + public long getSentTimestamp() { + return message.getTimestamp(); + } + + @Override + public boolean shouldIncludeInMessageLog() { + return false; + } + + @Override + public long getRelatedMessageId() { + return -1; + } + + @Override + public boolean isRelatedMessageMms() { + return false; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java index 28bd2bda19..2a649247c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageProcessor.java @@ -160,8 +160,10 @@ public class IncomingMessageProcessor { private void processReceipt(@NonNull SignalServiceEnvelope envelope) { Log.i(TAG, "Received server receipt for " + envelope.getTimestamp()); - mmsSmsDatabase.incrementDeliveryReceiptCount(new SyncMessageId(Recipient.externalHighTrustPush(context, envelope.getSourceAddress()).getId(), envelope.getTimestamp()), - System.currentTimeMillis()); + Recipient sender = Recipient.externalHighTrustPush(context, envelope.getSourceAddress()); + + mmsSmsDatabase.incrementDeliveryReceiptCount(new SyncMessageId(sender.getId(), envelope.getTimestamp()), System.currentTimeMillis()); + DatabaseFactory.getMessageLogDatabase(context).deleteEntryForRecipient(envelope.getTimestamp(), sender.getId(), envelope.getSourceDevice()); } private boolean needsToEnqueueDecryption() { 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 9d55949284..e8fc31b402 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -24,10 +24,12 @@ import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.SecurityEvent; +import org.thoughtcrime.securesms.crypto.SessionUtil; import org.thoughtcrime.securesms.crypto.storage.TextSecureSessionStore; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo; import org.thoughtcrime.securesms.database.MessageDatabase; @@ -40,10 +42,10 @@ import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageLogEntry; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; -import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -65,14 +67,15 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackSyncJob; +import org.thoughtcrime.securesms.jobs.NullMessageSendJob; import org.thoughtcrime.securesms.jobs.PaymentLedgerUpdateJob; import org.thoughtcrime.securesms.jobs.PaymentTransactionCheckJob; import org.thoughtcrime.securesms.jobs.ProfileKeySendJob; -import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushProcessMessageJob; import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; import org.thoughtcrime.securesms.jobs.RequestGroupInfoJob; +import org.thoughtcrime.securesms.jobs.ResendMessageJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob; import org.thoughtcrime.securesms.jobs.SenderKeyDistributionSendJob; @@ -152,6 +155,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.IOException; import java.security.SecureRandom; @@ -241,7 +245,7 @@ public final class MessageContentProcessor { if (isGv2Message) { GroupId.V2 groupIdV2 = groupId.get().requireV2(); - Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2); + Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2); if (possibleGv1.isPresent()) { GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1()); } @@ -956,7 +960,7 @@ public final class MessageContentProcessor { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); if (message.getMessage().isGroupV2Message()) { - Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(GroupId.v2(message.getMessage().getGroupContext().get().getGroupV2().get().getMasterKey())); + Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(GroupId.v2(message.getMessage().getGroupContext().get().getGroupV2().get().getMasterKey())); if (possibleGv1.isPresent()) { GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1()); } @@ -1611,6 +1615,7 @@ public final class MessageContentProcessor { .toList(); DatabaseFactory.getMmsSmsDatabase(context).incrementDeliveryReceiptCounts(ids, System.currentTimeMillis()); + DatabaseFactory.getMessageLogDatabase(context).deleteEntriesForRecipient(message.getTimestamps(), sender.getId(), content.getSenderDevice()); } @SuppressLint("DefaultLocale") @@ -1680,76 +1685,142 @@ public final class MessageContentProcessor { private void handleRetryReceipt(@NonNull SignalServiceContent content, @NonNull DecryptionErrorMessage decryptionErrorMessage) { if (!FeatureFlags.senderKey()) { - Log.w(TAG, "Sender key not enabled, skipping retry receipt."); + warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Sender key not enabled, skipping retry receipt."); return; } Recipient requester = Recipient.externalHighTrustPush(context, content.getSender()); long sentTimestamp = decryptionErrorMessage.getTimestamp(); + warn(content.getTimestamp(), "[RetryReceipt] Received a retry receipt from " + requester.getId() + ", device " + decryptionErrorMessage.getDeviceId() + " for message with timestamp " + sentTimestamp + "."); + if (!requester.hasUuid()) { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Requester " + requester.getId() + " somehow has no UUID! timestamp: " + sentTimestamp); + warn(content.getTimestamp(), "[RetryReceipt] Requester " + requester.getId() + " somehow has no UUID! timestamp: " + sentTimestamp); return; } - MessageRecord messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(sentTimestamp, Recipient.self().getId()); + MessageLogEntry messageLogEntry = DatabaseFactory.getMessageLogDatabase(context).getLogEntry(requester.getId(), content.getSenderDevice(), sentTimestamp); - if (messageRecord == null) { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Unable to find message for " + requester.getId() + " with timestamp " + sentTimestamp); - // TODO Send distribution message? + if (decryptionErrorMessage.getRatchetKey().isPresent()) { + handleIndividualRetryReceipt(requester, messageLogEntry, content, decryptionErrorMessage); + } else { + handleSenderKeyRetryReceipt(requester, messageLogEntry, content, decryptionErrorMessage); + } + } + + private void handleSenderKeyRetryReceipt(@NonNull Recipient requester, + @Nullable MessageLogEntry messageLogEntry, + @NonNull SignalServiceContent content, + @NonNull DecryptionErrorMessage decryptionErrorMessage) + { + long sentTimestamp = decryptionErrorMessage.getTimestamp(); + MessageRecord relatedMessage = findRetryReceiptRelatedMessage(context, messageLogEntry, sentTimestamp); + + if (relatedMessage == null) { + warn(content.getTimestamp(), "[RetryReceipt-SK] The related message could not be found! There shouldn't be any sender key resends where we can't find the related message. Skipping."); return; } - Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(messageRecord.getThreadId()); - + Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(relatedMessage.getThreadId()); if (threadRecipient == null) { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Unable to find a recipient for thread " + messageRecord.getThreadId()); + warn(content.getTimestamp(), "[RetryReceipt-SK] Could not find a thread recipient! Skipping."); return; } - if (messageRecord.isMms()) { - log(String.valueOf(content.getTimestamp()), "[RetryReceipt] MMS " + messageRecord.getId()); - MmsMessageRecord mms = (MmsMessageRecord) messageRecord; + if (!threadRecipient.isPushV2Group()) { + warn(content.getTimestamp(), "[RetryReceipt-SK] Thread recipient is not a v2 group! Skipping."); + return; + } - if (threadRecipient.isPushV2Group()) { - DistributionId distributionId = DatabaseFactory.getGroupDatabase(context).getOrCreateDistributionId(threadRecipient.requireGroupId().requireV2()); - SignalProtocolAddress requesterAddress = new SignalProtocolAddress(requester.requireUuid().toString(), decryptionErrorMessage.getDeviceId()); + DistributionId distributionId = DatabaseFactory.getGroupDatabase(context).getOrCreateDistributionId(threadRecipient.requireGroupId().requireV2()); - DatabaseFactory.getSenderKeySharedDatabase(context).delete(distributionId, Collections.singleton(requesterAddress)); + SignalProtocolAddress requesterAddress = new SignalProtocolAddress(requester.requireUuid().toString(), decryptionErrorMessage.getDeviceId()); + DatabaseFactory.getSenderKeySharedDatabase(context).delete(distributionId, Collections.singleton(requesterAddress)); - GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context); - GroupReceiptInfo receiptInfo = receiptDatabase.getGroupReceiptInfo(mms.getId(), requester.getId()); - boolean needsDistributionMessage = true; + if (messageLogEntry != null) { + warn(content.getTimestamp(), "[RetryReceipt-SK] Found MSL entry for " + requester.getId() + " with timestamp " + sentTimestamp + ". Scheduling a resend."); - if (receiptInfo == null) { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Requester was never sent message " + mms.getId() + "! Cannot resend it."); - } else if (receiptInfo.getStatus() >= GroupReceiptDatabase.STATUS_DELIVERED) { - log(String.valueOf(content.getTimestamp()), "[RetryReceipt] The message was successfully delivered to the requester. Not resending."); - } else { - long messageAge = System.currentTimeMillis() - mms.getDateSent(); + ApplicationDependencies.getJobManager().add(new ResendMessageJob(messageLogEntry.getRecipientId(), + messageLogEntry.getDateSent(), + messageLogEntry.getContent(), + messageLogEntry.getContentHint(), + threadRecipient.requireGroupId().requireV2(), + distributionId)); + } else { + warn(content.getTimestamp(), "[RetryReceipt-SK] Unable to find MSL entry for " + requester.getId() + " with timestamp " + sentTimestamp + "."); - if (messageAge < FeatureFlags.retryRespondMaxAge()) { - log(String.valueOf(content.getTimestamp()), "[RetryReceipt] The message was successfully sent to the requester, but not delivered. Resending."); + if (!content.getGroupId().isPresent()) { + warn(content.getTimestamp(), "[RetryReceipt-SK] No groupId on the Content, so we cannot send them a SenderKeyDistributionMessage."); + return; + } - DatabaseFactory.getGroupReceiptDatabase(context).update(requester.getId(), mms.getId(), GroupReceiptDatabase.STATUS_UNDELIVERED, System.currentTimeMillis()); - ApplicationDependencies.getJobManager().startChain(new SenderKeyDistributionSendJob(requester.getId(), threadRecipient.requireGroupId().requireV2())) - .then(new PushGroupSendJob(mms.getId(), threadRecipient.getId(), requester.getId(), false)) - .enqueue(); + GroupId groupId; + try { + groupId = GroupId.push(content.getGroupId().get()); + } catch (BadGroupIdException e) { + warn(String.valueOf(content.getTimestamp()), "[RetryReceipt-SK] Bad groupId!"); + return; + } - needsDistributionMessage = false; - } else { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] The message was successfully sent to the requester, but not delivered. But it's " + messageAge + " ms old, so we're not resending."); - } - } + if (!groupId.isV2()) { + warn(String.valueOf(content.getTimestamp()), "[RetryReceipt-SK] Not a valid GV2 ID!"); + return; + } - if (needsDistributionMessage && threadRecipient.getParticipants().contains(requester)) { - warn(String.valueOf(content.getTimestamp()), "[RetryReceipt] Requester is, however, in the group now. Sending distribution message."); - ApplicationDependencies.getJobManager().add(new SenderKeyDistributionSendJob(requester.getId(), threadRecipient.requireGroupId().requireV2())); - } + Optional groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId); + + if (!groupRecord.isPresent()) { + warn(content.getTimestamp(), "[RetryReceipt-SK] Could not find a record for the group!"); + return; + } + + if (!groupRecord.get().getMembers().contains(requester.getId())) { + warn(content.getTimestamp(), "[RetryReceipt-SK] The requester is not in the group, so we cannot send them a SenderKeyDistributionMessage."); + return; + } + + warn(content.getTimestamp(), "[RetryReceipt-SK] The requester is in the group, so we'll send them a SenderKeyDistributionMessage."); + ApplicationDependencies.getJobManager().add(new SenderKeyDistributionSendJob(requester.getId(), groupRecord.get().getId().requireV2())); + } + } + + private void handleIndividualRetryReceipt(@NonNull Recipient requester, @Nullable MessageLogEntry messageLogEntry, @NonNull SignalServiceContent content, @NonNull DecryptionErrorMessage decryptionErrorMessage) { + boolean archivedSession = false; + + if (decryptionErrorMessage.getDeviceId() == SignalServiceAddress.DEFAULT_DEVICE_ID && + decryptionErrorMessage.getRatchetKey().isPresent() && + SessionUtil.ratchetKeyMatches(context, requester, content.getSenderDevice(), decryptionErrorMessage.getRatchetKey().get())) + { + warn(content.getTimestamp(), "[RetryReceipt-I] Ratchet key matches. Archiving the session."); + SessionUtil.archiveSession(context, requester.getId(), content.getSenderDevice()); + archivedSession = true; + } + + if (messageLogEntry != null) { + warn(content.getTimestamp(), "[RetryReceipt-I] Found an entry in the MSL. Resending."); + ApplicationDependencies.getJobManager().add(new ResendMessageJob(messageLogEntry.getRecipientId(), + messageLogEntry.getDateSent(), + messageLogEntry.getContent(), + messageLogEntry.getContentHint(), + null, + null)); + } else if (archivedSession) { + warn(content.getTimestamp(), "[RetryReceipt-I] Could not find an entry in the MSL, but we archived the session, so we're sending a null message to complete the reset."); + ApplicationDependencies.getJobManager().add(new NullMessageSendJob(requester.getId())); + } else { + warn(content.getTimestamp(), "[RetryReceipt-I] Could not find an entry in the MSL. Skipping."); + } + } + + private static @Nullable MessageRecord findRetryReceiptRelatedMessage(@NonNull Context context, @Nullable MessageLogEntry messageLogEntry, long sentTimestamp) { + if (messageLogEntry != null && messageLogEntry.hasRelatedMessage()) { + if (messageLogEntry.isRelatedMessageMms()) { + return DatabaseFactory.getMmsDatabase(context).getMessageRecordOrNull(messageLogEntry.getRelatedMessageId()); + } else { + return DatabaseFactory.getSmsDatabase(context).getMessageRecordOrNull(messageLogEntry.getRelatedMessageId()); } } else { - log(String.valueOf(content.getTimestamp()), "[RetryReceipt] SMS " + messageRecord.getId()); - SmsMessageRecord sms = (SmsMessageRecord) messageRecord; + return DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(sentTimestamp, Recipient.self().getId()); } } @@ -2092,6 +2163,10 @@ public final class MessageContentProcessor { warn(extra, message, null); } + protected void warn(long timestamp, @NonNull String message) { + warn(String.valueOf(timestamp), message); + } + protected void warn(@NonNull String message, @Nullable Throwable t) { warn("", message, t); } 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 89f70c435f..d7081eb1c9 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 @@ -219,18 +219,16 @@ public class SignalServiceMessageSender { * * @param recipient The sender of the received message you're acknowledging. * @param message The read receipt to deliver. - * @throws IOException - * @throws UntrustedIdentityException */ - public void sendReceipt(SignalServiceAddress recipient, - Optional unidentifiedAccess, - SignalServiceReceiptMessage message) + public SendMessageResult sendReceipt(SignalServiceAddress recipient, + Optional unidentifiedAccess, + SignalServiceReceiptMessage message) throws IOException, UntrustedIdentityException { Content content = createReceiptContent(message); EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.IMPLICIT, Optional.absent()); - sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), message.getWhen(), envelopeContent, false, null); + return sendMessage(recipient, getTargetUnidentifiedAccess(unidentifiedAccess), message.getWhen(), envelopeContent, false, null); } /** @@ -254,8 +252,6 @@ public class SignalServiceMessageSender { * * @param recipient The destination * @param message The typing indicator to deliver - * @throws IOException - * @throws UntrustedIdentityException */ public void sendTyping(SignalServiceAddress recipient, Optional unidentifiedAccess, @@ -402,6 +398,23 @@ public class SignalServiceMessageSender { new SignalGroupSessionBuilder(sessionLock, new GroupSessionBuilder(store)).process(sender, senderKeyDistributionMessage); } + /** + * Resend a previously-sent message. + */ + public SendMessageResult resendContent(SignalServiceAddress address, + Optional unidentifiedAccess, + long timestamp, + Content content, + ContentHint contentHint, + Optional groupId) + throws UntrustedIdentityException, IOException + { + EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, contentHint, groupId); + Optional access = unidentifiedAccess.isPresent() ? unidentifiedAccess.get().getTargetUnidentifiedAccess() : Optional.absent(); + + return sendMessage(address, access, timestamp, envelopeContent, false, null); + } + /** * Sends a {@link SignalServiceDataMessage} to a group using sender keys. */ @@ -469,7 +482,7 @@ public class SignalServiceMessageSender { return results; } - public void sendSyncMessage(SignalServiceSyncMessage message, Optional unidentifiedAccess) + public SendMessageResult sendSyncMessage(SignalServiceSyncMessage message, Optional unidentifiedAccess) throws IOException, UntrustedIdentityException { Content content; @@ -502,8 +515,7 @@ public class SignalServiceMessageSender { } else if (message.getKeys().isPresent()) { content = createMultiDeviceSyncKeysContent(message.getKeys().get()); } else if (message.getVerified().isPresent()) { - sendVerifiedMessage(message.getVerified().get(), unidentifiedAccess); - return; + return sendVerifiedMessage(message.getVerified().get(), unidentifiedAccess); } else { throw new IOException("Unsupported sync message!"); } @@ -513,7 +525,7 @@ public class SignalServiceMessageSender { EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.IMPLICIT, Optional.absent()); - sendMessage(localAddress, Optional.absent(), timestamp, envelopeContent, false, null); + return sendMessage(localAddress, Optional.absent(), timestamp, envelopeContent, false, null); } public void setSoTimeoutMillis(long soTimeoutMillis) { @@ -631,7 +643,7 @@ public class SignalServiceMessageSender { attachment.getUploadTimestamp()); } - private void sendVerifiedMessage(VerifiedMessage message, Optional unidentifiedAccess) + private SendMessageResult sendVerifiedMessage(VerifiedMessage message, Optional unidentifiedAccess) throws IOException, UntrustedIdentityException { byte[] nullMessageBody = DataMessage.newBuilder() @@ -657,6 +669,8 @@ public class SignalServiceMessageSender { sendMessage(localAddress, Optional.absent(), message.getTimestamp(), syncMessageContent, false, null); } + + return result; } public SendMessageResult sendNullMessage(SignalServiceAddress address, Optional unidentifiedAccess) @@ -1016,9 +1030,10 @@ public class SignalServiceMessageSender { private Content createMultiDeviceSentTranscriptContent(SentTranscriptMessage transcript, Optional unidentifiedAccess) throws IOException { SignalServiceAddress address = transcript.getDestination().get(); - SendMessageResult result = SendMessageResult.success(address, unidentifiedAccess.isPresent(), true, -1); + Content content = createMessageContent(transcript.getMessage()); + SendMessageResult result = SendMessageResult.success(address, Collections.emptyList(), unidentifiedAccess.isPresent(), true, -1, Optional.of(content)); - return createMultiDeviceSentTranscriptContent(createMessageContent(transcript.getMessage()), + return createMultiDeviceSentTranscriptContent(content, Optional.of(address), transcript.getTimestamp(), Collections.singletonList(result), @@ -1613,7 +1628,7 @@ public class SignalServiceMessageSender { if (pipe.isPresent() && !unidentifiedAccess.isPresent()) { try { SendMessageResponse response = pipe.get().send(messages, Optional.absent()).get(10, TimeUnit.SECONDS); - return SendMessageResult.success(recipient, false, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime); + return SendMessageResult.success(recipient, messages.getDevices(), false, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime, content.getContent()); } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { Log.w(TAG, e); Log.w(TAG, "[sendMessage] Pipe failed, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")"); @@ -1621,7 +1636,7 @@ public class SignalServiceMessageSender { } else if (unidentifiedPipe.isPresent() && unidentifiedAccess.isPresent()) { try { SendMessageResponse response = unidentifiedPipe.get().send(messages, unidentifiedAccess).get(10, TimeUnit.SECONDS); - return SendMessageResult.success(recipient, true, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime); + return SendMessageResult.success(recipient, messages.getDevices(), true, response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime, content.getContent()); } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { Log.w(TAG, e); Log.w(TAG, "[sendMessage] Unidentified pipe failed, falling back..."); @@ -1634,7 +1649,7 @@ public class SignalServiceMessageSender { SendMessageResponse response = socket.sendMessage(messages, unidentifiedAccess); - return SendMessageResult.success(recipient, unidentifiedAccess.isPresent(), response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime); + return SendMessageResult.success(recipient, messages.getDevices(), unidentifiedAccess.isPresent(), response.getNeedsSync() || isMultiDevice.get(), System.currentTimeMillis() - startTime, content.getContent()); } catch (InvalidKeyException ike) { Log.w(TAG, ike); @@ -1693,15 +1708,21 @@ public class SignalServiceMessageSender { accessByUuid.put(addressIterator.next().getUuid().get(), accessIterator.next()); } + Map> recipientDevices = recipients.stream().collect(Collectors.toMap(a -> a, a -> new LinkedList<>())); + for (int i = 0; i < RETRY_COUNT; i++) { List destinations = new LinkedList<>(); for (SignalServiceAddress recipient : recipients) { + List devices = recipientDevices.get(recipient); + destinations.add(new SignalProtocolAddress(recipient.getUuid().get().toString(), SignalServiceAddress.DEFAULT_DEVICE_ID)); + devices.add(SignalServiceAddress.DEFAULT_DEVICE_ID); for (int deviceId : store.getSubDeviceSessions(recipient.getIdentifier())) { if (store.containsSession(new SignalProtocolAddress(recipient.getIdentifier(), deviceId))) { destinations.add(new SignalProtocolAddress(recipient.getUuid().get().toString(), deviceId)); + devices.add(deviceId); } } } @@ -1783,7 +1804,7 @@ public class SignalServiceMessageSender { if (pipe.isPresent()) { try { SendGroupMessageResponse response = pipe.get().sendToGroup(ciphertext, joinedUnidentifiedAccess, timestamp, online).get(10, TimeUnit.SECONDS); - return transformGroupResponseToMessageResults(recipients, response); + return transformGroupResponseToMessageResults(recipientDevices, response, content); } catch (IOException | ExecutionException | InterruptedException | TimeoutException e) { Log.w(TAG, "[sendGroupMessage] Pipe failed, falling back... (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")"); } @@ -1793,7 +1814,7 @@ public class SignalServiceMessageSender { try { SendGroupMessageResponse response = socket.sendGroupMessage(ciphertext, joinedUnidentifiedAccess, timestamp, online); - return transformGroupResponseToMessageResults(recipients, response); + return transformGroupResponseToMessageResults(recipientDevices, response, content); } catch (GroupMismatchedDevicesException e) { Log.w(TAG, "[sendGroupMessage] Handling mismatched devices.", e); for (GroupMismatchedDevices mismatched : e.getMismatchedDevices()) { @@ -1822,7 +1843,7 @@ public class SignalServiceMessageSender { throw new IOException("Failed to resolve conflicts after " + RETRY_COUNT + " attempts!"); } - private List transformGroupResponseToMessageResults(List recipients, SendGroupMessageResponse response) { + private List transformGroupResponseToMessageResults(Map> recipients, SendGroupMessageResponse response, Content content) { Set unregistered = response.getUnsentTargets(); List failures = unregistered.stream() @@ -1830,12 +1851,14 @@ public class SignalServiceMessageSender { .map(SendMessageResult::unregisteredFailure) .collect(Collectors.toList()); - List success = recipients.stream() + List success = recipients.keySet() + .stream() .filter(r -> !unregistered.contains(r.getUuid().get())) - .map(a -> SendMessageResult.success(a, true, isMultiDevice.get(), -1)) + .map(a -> SendMessageResult.success(a, recipients.get(a), true, isMultiDevice.get(), -1, Optional.of(content))) .collect(Collectors.toList()); - List results = new LinkedList<>(success); + List results = new ArrayList<>(success.size() + failures.size()); + results.addAll(success); results.addAll(failures); return results; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ContentHint.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ContentHint.java index 4f7b4082f8..c268cf4840 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ContentHint.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/ContentHint.java @@ -6,7 +6,7 @@ import java.util.HashMap; import java.util.Map; public enum ContentHint { - /** This message has content, but you shouldn’t expect it to be re-sent to you. */ + /** This message has content, but you shouldn't expect it to be re-sent to you. */ DEFAULT(UnidentifiedSenderMessageContent.CONTENT_HINT_DEFAULT), /** You should expect to be able to have this content be re-sent to you. */ diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/EnvelopeContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/EnvelopeContent.java index 5166f98b88..da60c60358 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/EnvelopeContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/EnvelopeContent.java @@ -39,12 +39,17 @@ public interface EnvelopeContent { */ int size(); + /** + * A content proto, if applicable. + */ + Optional getContent(); + /** * Wrap {@link Content} you plan on sending as an encrypted message. * This is the default. Consider anything else exceptional. */ static EnvelopeContent encrypted(Content content, ContentHint contentHint, Optional groupId) { - return new Encrypted(content.toByteArray(), contentHint, groupId); + return new Encrypted(content, contentHint, groupId); } /** @@ -56,14 +61,14 @@ public interface EnvelopeContent { class Encrypted implements EnvelopeContent { - private final byte[] unpaddedMessage; + private final Content content; private final ContentHint contentHint; private final Optional groupId; - public Encrypted(byte[] unpaddedMessage, ContentHint contentHint, Optional groupId) { - this.unpaddedMessage = unpaddedMessage; - this.contentHint = contentHint; - this.groupId = groupId; + public Encrypted(Content content, ContentHint contentHint, Optional groupId) { + this.content = content; + this.contentHint = contentHint; + this.groupId = groupId; } @Override @@ -74,7 +79,7 @@ public interface EnvelopeContent { throws UntrustedIdentityException, InvalidKeyException { PushTransportDetails transportDetails = new PushTransportDetails(); - CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(unpaddedMessage)); + CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.toByteArray())); UnidentifiedSenderMessageContent messageContent = new UnidentifiedSenderMessageContent(message, senderCertificate, contentHint.getType(), @@ -90,7 +95,7 @@ public interface EnvelopeContent { @Override public OutgoingPushMessage processUnsealedSender(SignalSessionCipher sessionCipher, SignalProtocolAddress destination) throws UntrustedIdentityException { PushTransportDetails transportDetails = new PushTransportDetails(); - CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(unpaddedMessage)); + CiphertextMessage message = sessionCipher.encrypt(transportDetails.getPaddedMessageBody(content.toByteArray())); int remoteRegistrationId = sessionCipher.getRemoteRegistrationId(); String body = Base64.encodeBytes(message.serialize()); @@ -107,7 +112,12 @@ public interface EnvelopeContent { @Override public int size() { - return unpaddedMessage.length; + return content.getSerializedSize(); + } + + @Override + public Optional getContent() { + return Optional.of(content); } } @@ -152,5 +162,10 @@ public interface EnvelopeContent { public int size() { return plaintextContent.getBody().length; } + + @Override + public Optional getContent() { + return Optional.absent(); + } } } 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 5e8a626f73..8db8a5b3b5 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 @@ -2,8 +2,13 @@ package org.whispersystems.signalservice.api.messages; import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content; + +import java.util.List; public class SendMessageResult { @@ -14,8 +19,8 @@ public class SendMessageResult { 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, null); + public static SendMessageResult success(SignalServiceAddress address, List devices, boolean unidentified, boolean needsSync, long duration, Optional content) { + return new SendMessageResult(address, new Success(unidentified, needsSync, duration, content, devices), false, false, null, null); } public static SendMessageResult networkFailure(SignalServiceAddress address) { @@ -78,14 +83,18 @@ public class SendMessageResult { } public static class Success { - private final boolean unidentified; - private final boolean needsSync; - private final long duration; + private final boolean unidentified; + private final boolean needsSync; + private final long duration; + private final Optional content; + private final List devices; - private Success(boolean unidentified, boolean needsSync, long duration) { + private Success(boolean unidentified, boolean needsSync, long duration, Optional content, List devices) { this.unidentified = unidentified; this.needsSync = needsSync; this.duration = duration; + this.content = content; + this.devices = devices; } public boolean isUnidentified() { @@ -99,6 +108,14 @@ public class SendMessageResult { public long getDuration() { return duration; } + + public Optional getContent() { + return content; + } + + public List getDevices() { + return devices; + } } public static class IdentityFailure { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java index eb8e0c8bb3..8cf9eae90c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessage.java @@ -30,4 +30,8 @@ public class OutgoingPushMessage { this.destinationRegistrationId = destinationRegistrationId; this.content = content; } + + public int getDestinationDeviceId() { + return destinationDeviceId; + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java index 50965212c0..5c90b79022 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/OutgoingPushMessageList.java @@ -6,9 +6,11 @@ package org.whispersystems.signalservice.internal.push; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.stream.Collectors; public class OutgoingPushMessageList { @@ -50,4 +52,9 @@ public class OutgoingPushMessageList { public boolean isOnline() { return online; } + + @JsonIgnore + public List getDevices() { + return messages.stream().map(OutgoingPushMessage::getDestinationDeviceId).collect(Collectors.toList()); + } }