From 6ecd3b59fdeaf7c1d4401cfdfe2ced5305500e24 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 15 Apr 2020 14:56:58 -0400 Subject: [PATCH] Add pre-alpha receive support for remote delete. --- .../conversation/ConversationFragment.java | 107 ++++++--- .../conversation/ConversationItem.java | 11 +- .../ConversationReactionOverlay.java | 1 + .../securesms/conversation/MenuState.java | 30 ++- .../securesms/database/MessagingDatabase.java | 3 + .../securesms/database/MmsDatabase.java | 35 ++- .../securesms/database/MmsSmsColumns.java | 1 + .../securesms/database/MmsSmsDatabase.java | 12 +- .../securesms/database/SmsDatabase.java | 29 ++- .../securesms/database/ThreadDatabase.java | 31 ++- .../database/helpers/SQLCipherOpenHelper.java | 8 +- .../database/model/MediaMmsMessageRecord.java | 5 +- .../database/model/MessageRecord.java | 8 +- .../database/model/MmsMessageRecord.java | 4 +- .../model/NotificationMmsMessageRecord.java | 2 +- .../database/model/SmsMessageRecord.java | 4 +- .../database/model/ThreadRecord.java | 4 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/PushProcessMessageJob.java | 46 +++- .../securesms/jobs/RemoteDeleteSendJob.java | 212 ++++++++++++++++++ .../notifications/MessageNotifier.java | 2 + .../securesms/sms/MessageSender.java | 14 ++ .../securesms/util/FeatureFlags.java | 9 +- .../securesms/util/RemoteDeleteUtil.java | 37 +++ app/src/main/res/values/strings.xml | 7 +- .../api/SignalServiceMessageSender.java | 7 + .../api/messages/SignalServiceContent.java | 14 +- .../messages/SignalServiceDataMessage.java | 28 ++- .../src/main/proto/SignalService.proto | 5 + 29 files changed, 595 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 3ad30c10e8..44bf835c3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -110,6 +110,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.HtmlUtil; +import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -602,6 +603,14 @@ public class ConversationFragment extends Fragment } private void handleDeleteMessages(final Set messageRecords) { + if (FeatureFlags.remoteDelete()) { + buildRemoteDeleteConfirmationDialog(messageRecords).show(); + } else { + buildLegacyDeleteConfirmationDialog(messageRecords).show(); + } + } + + private AlertDialog.Builder buildLegacyDeleteConfirmationDialog(Set messageRecords) { int messagesCount = messageRecords.size(); AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); @@ -610,40 +619,87 @@ public class ConversationFragment extends Fragment builder.setMessage(getActivity().getResources().getQuantityString(R.plurals.ConversationFragment_this_will_permanently_delete_all_n_selected_messages, messagesCount, messagesCount)); builder.setCancelable(true); - builder.setPositiveButton(R.string.delete, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - new ProgressDialogAsyncTask(getActivity(), - R.string.ConversationFragment_deleting, - R.string.ConversationFragment_deleting_messages) - { - @Override - protected Void doInBackground(MessageRecord... messageRecords) { - for (MessageRecord messageRecord : messageRecords) { - boolean threadDeleted; + builder.setPositiveButton(R.string.delete, (dialog, which) -> { + new ProgressDialogAsyncTask(getActivity(), + R.string.ConversationFragment_deleting, + R.string.ConversationFragment_deleting_messages) + { + @Override + protected Void doInBackground(Void... voids) { + for (MessageRecord messageRecord : messageRecords) { + boolean threadDeleted; - if (messageRecord.isMms()) { - threadDeleted = DatabaseFactory.getMmsDatabase(getActivity()).delete(messageRecord.getId()); - } else { - threadDeleted = DatabaseFactory.getSmsDatabase(getActivity()).deleteMessage(messageRecord.getId()); - } - - if (threadDeleted) { - threadId = -1; - listener.setThreadId(threadId); - } + if (messageRecord.isMms()) { + threadDeleted = DatabaseFactory.getMmsDatabase(getActivity()).delete(messageRecord.getId()); + } else { + threadDeleted = DatabaseFactory.getSmsDatabase(getActivity()).deleteMessage(messageRecord.getId()); } - return null; + if (threadDeleted) { + threadId = -1; + listener.setThreadId(threadId); + } } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, messageRecords.toArray(new MessageRecord[messageRecords.size()])); - } + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); }); builder.setNegativeButton(android.R.string.cancel, null); - builder.show(); + return builder; } + private AlertDialog.Builder buildRemoteDeleteConfirmationDialog(Set messageRecords) { + Context context = requireActivity(); + int messagesCount = messageRecords.size(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + + builder.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messagesCount, messagesCount)); + builder.setCancelable(true); + + builder.setPositiveButton(R.string.ConversationFragment_delete_for_me, (dialog, which) -> { + new ProgressDialogAsyncTask(getActivity(), + R.string.ConversationFragment_deleting, + R.string.ConversationFragment_deleting_messages) + { + @Override + protected Void doInBackground(Void... voids) { + for (MessageRecord messageRecord : messageRecords) { + boolean threadDeleted; + + if (messageRecord.isMms()) { + threadDeleted = DatabaseFactory.getMmsDatabase(context).delete(messageRecord.getId()); + } else { + threadDeleted = DatabaseFactory.getSmsDatabase(context).deleteMessage(messageRecord.getId()); + } + + if (threadDeleted) { + threadId = -1; + listener.setThreadId(threadId); + } + } + + return null; + } + }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + }); + + if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis())) { + builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> { + SignalExecutors.BOUNDED.execute(() -> { + for (MessageRecord message : messageRecords) { + MessageSender.sendRemoteDelete(context, message.getId(), message.isMms()); + } + }); + }); + } + + builder.setNegativeButton(android.R.string.cancel, null); + return builder; + } + + private void handleDisplayDetails(MessageRecord message) { Intent intent = new Intent(getActivity(), MessageDetailsActivity.class); intent.putExtra(MessageDetailsActivity.MESSAGE_ID_EXTRA, message.getId()); @@ -1086,6 +1142,7 @@ public class ConversationFragment extends Fragment if (actionMode != null) return; if (messageRecord.isSecure() && + !messageRecord.isRemoteDelete() && !messageRecord.isUpdate() && !recipient.get().isBlocked() && !messageRequestViewModel.shouldShowMessageRequest() && diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 442823e15f..137dd674c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -36,6 +36,8 @@ import android.text.style.BackgroundColorSpan; import android.text.style.CharacterStyle; import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StyleSpan; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; @@ -519,7 +521,14 @@ public class ConversationItem extends LinearLayout implements BindableConversati bodyText.setFocusable(false); bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context)); - if (isCaptionlessMms(messageRecord)) { + if (messageRecord.isRemoteDelete()) { + String deletedMessage = context.getString(R.string.ConversationItem_this_message_was_deleted); + SpannableString italics = new SpannableString(deletedMessage); + italics.setSpan(new RelativeSizeSpan(0.9f), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + bodyText.setText(italics); + } else if (isCaptionlessMms(messageRecord)) { bodyText.setVisibility(View.GONE); } else { Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(getContext()), batchSelected.isEmpty()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index d93d8a7b08..46e3535410 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -432,6 +432,7 @@ public final class ConversationReactionOverlay extends RelativeLayout { toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction()); toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction()); toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction()); + toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction()); } private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index 4a74776a46..92de237d6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -59,6 +59,7 @@ final class MenuState { boolean hasText = false; boolean sharedContact = false; boolean viewOnce = false; + boolean remoteDelete = false; for (MessageRecord messageRecord : messageRecords) { if (isActionMessage(messageRecord)) @@ -77,6 +78,10 @@ final class MenuState { if (messageRecord.isViewOnce()) { viewOnce = true; } + + if (messageRecord.isRemoteDelete()) { + remoteDelete = true; + } } if (messageRecords.size() > 1) { @@ -89,26 +94,27 @@ final class MenuState { MessageRecord messageRecord = messageRecords.iterator().next(); builder.shouldShowResendAction(messageRecord.isFailed()) - .shouldShowSaveAttachmentAction(!actionMessage && - !viewOnce && - messageRecord.isMms() && - !messageRecord.isMmsNotification() && - ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && - ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null) - .shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce) + .shouldShowSaveAttachmentAction(!actionMessage && + !viewOnce && + messageRecord.isMms() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null) + .shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce && !remoteDelete) .shouldShowDetailsAction(!actionMessage) .shouldShowReplyAction(canReplyToMessage(actionMessage, messageRecord, shouldShowMessageRequest)); } - return builder.shouldShowCopyAction(!actionMessage && hasText) + return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText) .build(); } static boolean canReplyToMessage(boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) { - return !actionMessage && - !messageRecord.isPending() && - !messageRecord.isFailed() && - !isDisplayingMessageRequest && + return !actionMessage && + !messageRecord.isRemoteDelete() && + !messageRecord.isPending() && + !messageRecord.isFailed() && + !isDisplayingMessageRequest && messageRecord.isSecure(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index 13aa4aef1a..ab99fa85a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -50,6 +50,9 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn public abstract void markAsSent(long messageId, boolean secure); public abstract void markUnidentified(long messageId, boolean unidentified); + public abstract void markAsSending(long messageId); + public abstract void markAsRemoteDelete(long messageId); + final int getInsecureMessagesSentForThread(long threadId) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String[] projection = new String[]{"COUNT(*)"}; 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 5c71a00ee2..54756b9b6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -168,7 +168,8 @@ public class MmsDatabase extends MessagingDatabase { VIEW_ONCE + " INTEGER DEFAULT 0, " + REACTIONS + " BLOB DEFAULT NULL, " + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + - REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);"; + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + + REMOTE_DELETED + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -193,6 +194,7 @@ public class MmsDatabase extends MessagingDatabase { DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, + REMOTE_DELETED, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + @@ -473,6 +475,7 @@ public class MmsDatabase extends MessagingDatabase { notifyConversationListeners(threadId); } + @Override public void markAsSending(long messageId) { long threadId = getThreadIdForMessage(messageId); updateMailboxBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE, Optional.of(threadId)); @@ -492,6 +495,29 @@ public class MmsDatabase extends MessagingDatabase { notifyConversationListeners(threadId); } + @Override + 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) }); + + DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentsForMessage(messageId); + + long threadId = getThreadIdForMessage(messageId); + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + public void markDownloadState(long messageId, long state) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); @@ -1506,7 +1532,8 @@ public class MmsDatabase extends MessagingDatabase { message.getSharedContacts(), message.getLinkPreviews(), false, - Collections.emptyList()); + Collections.emptyList(), + false); } } @@ -1597,6 +1624,7 @@ public class MmsDatabase extends MessagingDatabase { long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED)); boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; + boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1; List reactions = parseReactions(cursor); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { @@ -1618,7 +1646,8 @@ public class MmsDatabase extends MessagingDatabase { addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount, threadId, body, slideDeck, partCount, box, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, - isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions); + isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions, + remoteDelete); } private List getMismatchedIdentities(String document) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index e89be31264..f1f4b52df6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -24,6 +24,7 @@ public interface MmsSmsColumns { public static final String REACTIONS = "reactions"; public static final String REACTIONS_UNREAD = "reactions_unread"; public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; + public static final String REMOTE_DELETED = "remote_deleted"; public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 10a2cce9d9..0569106af7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -28,6 +28,7 @@ import net.sqlcipher.database.SQLiteQueryBuilder; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.Pair; @@ -87,7 +88,8 @@ public class MmsSmsDatabase extends Database { MmsSmsColumns.READ, MmsSmsColumns.REACTIONS, MmsSmsColumns.REACTIONS_UNREAD, - MmsSmsColumns.REACTIONS_LAST_SEEN}; + MmsSmsColumns.REACTIONS_LAST_SEEN, + MmsSmsColumns.REMOTE_DELETED}; public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -395,7 +397,8 @@ public class MmsSmsDatabase extends Database { MmsDatabase.REACTIONS, MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_LAST_SEEN, - MmsSmsColumns.DATE_SERVER }; + MmsSmsColumns.DATE_SERVER, + MmsSmsColumns.REMOTE_DELETED }; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, @@ -426,7 +429,8 @@ public class MmsSmsDatabase extends Database { MmsDatabase.REACTIONS, MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_LAST_SEEN, - MmsSmsColumns.DATE_SERVER }; + MmsSmsColumns.DATE_SERVER, + MmsSmsColumns.REMOTE_DELETED }; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); @@ -478,6 +482,7 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.REACTIONS); mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD); mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN); + mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); Set smsColumnsPresent = new HashSet<>(); smsColumnsPresent.add(MmsSmsColumns.ID); @@ -503,6 +508,7 @@ public class MmsSmsDatabase extends Database { smsColumnsPresent.add(SmsDatabase.REACTIONS); smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD); smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN); + smsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); @SuppressWarnings("deprecation") String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); 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 3867fd428e..74a6106aa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.IOException; import java.security.SecureRandom; @@ -103,7 +104,8 @@ public class SmsDatabase extends MessagingDatabase { UNIDENTIFIED + " INTEGER DEFAULT 0, " + REACTIONS + " BLOB DEFAULT NULL, " + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + - REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);"; + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + + REMOTE_DELETED + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -124,7 +126,8 @@ public class SmsDatabase extends MessagingDatabase { PROTOCOL, READ, STATUS, TYPE, REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, - NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN + NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, + REMOTE_DELETED }; private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")"; @@ -314,6 +317,7 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_TYPE | (isSecure ? Types.PUSH_MESSAGE_BIT | Types.SECURE_MESSAGE_BIT : 0)); } + @Override public void markAsSending(long id) { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENDING_TYPE); } @@ -322,6 +326,21 @@ public class SmsDatabase extends MessagingDatabase { updateTypeBitmask(id, Types.TOTAL_MASK, Types.MISSED_CALL_TYPE); } + @Override + public void markAsRemoteDelete(long id) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = new ContentValues(); + values.put(REMOTE_DELETED, 1); + values.putNull(BODY); + db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) }); + + long threadId = getThreadIdForMessage(id); + + DatabaseFactory.getThreadDatabase(context).update(threadId, false); + notifyConversationListeners(threadId); + } + @Override public void markUnidentified(long id, boolean unidentified) { ContentValues contentValues = new ContentValues(1); @@ -913,7 +932,8 @@ public class SmsDatabase extends MessagingDatabase { System.currentTimeMillis(), 0, false, - Collections.emptyList()); + Collections.emptyList(), + false); } } @@ -955,6 +975,7 @@ public class SmsDatabase extends MessagingDatabase { long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED)); String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1; + boolean remoteDelete = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.REMOTE_DELETED)) == 1; List reactions = parseReactions(cursor); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { @@ -970,7 +991,7 @@ public class SmsDatabase extends MessagingDatabase { dateSent, dateReceived, dateServer, deliveryReceiptCount, type, threadId, status, mismatches, subscriptionId, expiresIn, expireStarted, - readReceiptCount, unidentified, reactions); + readReceiptCount, unidentified, reactions, remoteDelete); } private List getMismatches(String document) { 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 4dee70da16..a34c9116ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -773,8 +773,10 @@ public class ThreadDatabase extends Database { return Extra.forMessageRequest(); } - if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { - return Extra.forRevealable(); + if (record.isViewOnce()) { + return Extra.forViewOnce(); + } else if (record.isRemoteDelete()) { + return Extra.forRemoteDelete(); } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { return Extra.forSticker(); } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) { @@ -899,43 +901,50 @@ public class ThreadDatabase extends Database { @JsonProperty private final boolean isRevealable; @JsonProperty private final boolean isSticker; @JsonProperty private final boolean isAlbum; + @JsonProperty private final boolean isRemoteDelete; @JsonProperty private final boolean isMessageRequestAccepted; @JsonProperty private final String groupAddedBy; public Extra(@JsonProperty("isRevealable") boolean isRevealable, @JsonProperty("isSticker") boolean isSticker, @JsonProperty("isAlbum") boolean isAlbum, + @JsonProperty("isRemoteDelete") boolean isRemoteDelete, @JsonProperty("isMessageRequestAccepted") boolean isMessageRequestAccepted, @JsonProperty("groupAddedBy") String groupAddedBy) { this.isRevealable = isRevealable; this.isSticker = isSticker; this.isAlbum = isAlbum; + this.isRemoteDelete = isRemoteDelete; this.isMessageRequestAccepted = isMessageRequestAccepted; this.groupAddedBy = groupAddedBy; } - public static @NonNull Extra forRevealable() { - return new Extra(true, false, false, true, null); + public static @NonNull Extra forViewOnce() { + return new Extra(true, false, false, false, true, null); } public static @NonNull Extra forSticker() { - return new Extra(false, true, false, true, null); + return new Extra(false, true, false, false, true, null); } public static @NonNull Extra forAlbum() { - return new Extra(false, false, true, true, null); + return new Extra(false, false, true, false, true, null); + } + + public static @NonNull Extra forRemoteDelete() { + return new Extra(false, false, false, true, true, null); } public static @NonNull Extra forMessageRequest() { - return new Extra(false, false, false, false, null); + return new Extra(false, false, false, false, false, null); } public static @NonNull Extra forGroupMessageRequest(RecipientId recipientId) { - return new Extra(false, false, false, false, recipientId.serialize()); + return new Extra(false, false, false, false, false, recipientId.serialize()); } - public boolean isRevealable() { + public boolean isViewOnce() { return isRevealable; } @@ -947,6 +956,10 @@ public class ThreadDatabase extends Database { return isAlbum; } + public boolean isRemoteDelete() { + return isRemoteDelete; + } + public boolean isMessageRequestAccepted() { return isMessageRequestAccepted; } 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 b51cb1810f..ba95b54018 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 @@ -129,8 +129,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int ATTACHMENT_CDN_NUMBER = 57; private static final int JOB_INPUT_DATA = 58; private static final int SERVER_TIMESTAMP = 59; + private static final int REMOTE_DELETE = 60; - private static final int DATABASE_VERSION = 59; + private static final int DATABASE_VERSION = 60; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -882,6 +883,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("CREATE INDEX IF NOT EXISTS mms_date_server_index ON mms (date_server)"); } + if (oldVersion < REMOTE_DELETE) { + db.execSQL("ALTER TABLE sms ADD COLUMN remote_deleted INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN remote_deleted INTEGER DEFAULT 0"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 0b5c7ff104..260db0b4c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -70,12 +70,13 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { @NonNull List contacts, @NonNull List linkPreviews, boolean unidentified, - @NonNull List reactions) + @NonNull List reactions, + boolean remoteDelete) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck, - readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions); + readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete); this.partCount = partCount; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index eca738c13b..46171104d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -55,6 +55,7 @@ public abstract class MessageRecord extends DisplayRecord { private final boolean unidentified; private final List reactions; private final long serverTimestamp; + private final boolean remoteDelete; MessageRecord(long id, String body, Recipient conversationRecipient, Recipient individualRecipient, int recipientDeviceId, @@ -64,7 +65,7 @@ public abstract class MessageRecord extends DisplayRecord { List networkFailures, int subscriptionId, long expiresIn, long expireStarted, int readReceiptCount, boolean unidentified, - @NonNull List reactions) + @NonNull List reactions, boolean remoteDelete) { super(body, conversationRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); @@ -79,6 +80,7 @@ public abstract class MessageRecord extends DisplayRecord { this.unidentified = unidentified; this.reactions = reactions; this.serverTimestamp = dateServer; + this.remoteDelete = remoteDelete; } public abstract boolean isMms(); @@ -259,6 +261,10 @@ public abstract class MessageRecord extends DisplayRecord { return false; } + public boolean isRemoteDelete() { + return remoteDelete; + } + public @NonNull List getReactions() { return reactions; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 0ce7c4f5dd..f2c37743e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -33,9 +33,9 @@ public abstract class MmsMessageRecord extends MessageRecord { @NonNull SlideDeck slideDeck, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, @NonNull List linkPreviews, boolean unidentified, - @NonNull List reactions) + @NonNull List reactions, boolean remoteDelete) { - super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions); + super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions, remoteDelete); this.slideDeck = slideDeck; this.quote = quote; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 596db81e06..92ed85c16c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -58,7 +58,7 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, new LinkedList<>(), new LinkedList<>(), subscriptionId, 0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false, - Collections.emptyList()); + Collections.emptyList(), false); this.contentLocation = contentLocation; this.messageSize = messageSize; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index fe76e19375..c4e04c71be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -49,12 +49,12 @@ public class SmsMessageRecord extends MessageRecord { int status, List mismatches, int subscriptionId, long expiresIn, long expireStarted, int readReceiptCount, boolean unidentified, - @NonNull List reactions) + @NonNull List reactions, boolean remoteDelete) { super(id, body, recipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, status, deliveryReceiptCount, type, mismatches, new LinkedList<>(), subscriptionId, - expiresIn, expireStarted, readReceiptCount, unidentified, reactions); + expiresIn, expireStarted, readReceiptCount, unidentified, reactions, remoteDelete); } public long getType() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index cecb13750f..325ed0143a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -127,8 +127,10 @@ public class ThreadRecord extends DisplayRecord { if (TextUtils.isEmpty(getBody())) { if (extra != null && extra.isSticker()) { return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_sticker))); - } else if (extra != null && extra.isRevealable()) { + } else if (extra != null && extra.isViewOnce()) { return new SpannableString(emphasisAdded(getViewOnceDescription(context, contentType))); + } else if (extra != null && extra.isRemoteDelete()) { + return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_this_message_was_deleted))); } else { return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message))); } 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 0f4c496f93..c73d84f105 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -86,6 +86,7 @@ public final class JobManagerFactories { put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory()); put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory()); + put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory()); put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 648c9d79f0..2246742e58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -79,6 +79,7 @@ import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.Hex; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.libsignal.state.SessionStore; import org.whispersystems.libsignal.util.guava.Optional; @@ -115,6 +116,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; public final class PushProcessMessageJob extends BaseJob { @@ -273,13 +275,14 @@ public final class PushProcessMessageJob extends BaseJob { return; } - if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId); - else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); - else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId); - else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId); - else if (message.getReaction().isPresent()) handleReaction(content, message); - else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); - else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId); + if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), groupId, content.getTimestamp(), smsMessageId); + else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); + else if (message.isGroupV1Update()) handleGroupV1Message(content, message, smsMessageId); + else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId); + else if (message.getReaction().isPresent()) handleReaction(content, message); + else if (message.getRemoteDelete().isPresent()) handleRemoteDelete(content, message); + else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); + else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId, groupId); if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { handleUnknownGroupMessage(content, message.getGroupContext().get()); @@ -606,7 +609,7 @@ public final class PushProcessMessageJob extends BaseJob { Recipient targetAuthor = Recipient.externalPush(context, reaction.getTargetAuthor()); MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId()); - if (targetMessage != null) { + if (targetMessage != null && !targetMessage.isRemoteDelete()) { Recipient reactionAuthor = Recipient.externalPush(context, content.getSender()); MessagingDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); @@ -618,12 +621,31 @@ public final class PushProcessMessageJob extends BaseJob { db.addReaction(targetMessage.getId(), reactionRecord); MessageNotifier.updateNotification(context, targetMessage.getThreadId(), false); } - + } else if (targetMessage != null) { + Log.w(TAG, "[handleReaction] Found a matching message, but it's flagged as remotely deleted. timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId()); } else { Log.w(TAG, "[handleReaction] Could not find matching message! timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId()); } } + private void handleRemoteDelete(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { + SignalServiceDataMessage.RemoteDelete delete = message.getRemoteDelete().get(); + + Recipient sender = Recipient.externalPush(context, content.getSender()); + MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(delete.getTargetSentTimestamp(), sender.getId()); + + if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, sender, content.getServerTimestamp())) { + MessagingDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + db.markAsRemoteDelete(targetMessage.getId()); + MessageNotifier.updateNotification(context, targetMessage.getThreadId(), false); + } else if (targetMessage == null) { + Log.w(TAG, "[handleRemoteDelete] Could not find matching message! timestamp: " + delete.getTargetSentTimestamp() + " author: " + sender.getId()); + } else { + Log.w(TAG, String.format(Locale.ENGLISH, "[handleRemoteDelete] Invalid remote delete! deleteTime: %d, targetTime: %d, deleteAuthor: %s, targetAuthor: %s", + content.getServerTimestamp(), targetMessage.getServerTimestamp(), sender.getId(), targetMessage.getRecipient().getId())); + } + } + private void handleSynchronizeVerifiedMessage(@NonNull VerifiedMessage verifiedMessage) { IdentityUtil.processVerifiedMessage(context, verifiedMessage); } @@ -751,6 +773,8 @@ public final class PushProcessMessageJob extends BaseJob { handleReaction(content, message.getMessage()); threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(getSyncMessageDestination(message)); threadId = threadId != -1 ? threadId : null; + } else if (message.getMessage().getRemoteDelete().isPresent()) { + handleRemoteDelete(content, message.getMessage()); } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent() || message.getMessage().isViewOnce()) { threadId = handleSynchronizeSentMediaMessage(message); } else { @@ -1392,7 +1416,7 @@ public final class PushProcessMessageJob extends BaseJob { RecipientId author = Recipient.externalPush(context, quote.get().getAuthor()).getId(); MessageRecord message = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quote.get().getId(), author); - if (message != null) { + if (message != null && !message.isRemoteDelete()) { Log.i(TAG, "Found matching message record..."); List attachments = new LinkedList<>(); @@ -1415,6 +1439,8 @@ public final class PushProcessMessageJob extends BaseJob { } return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments)); + } else if (message != null) { + Log.w(TAG, "Found the target for the quote, but it's flagged as remotely deleted."); } Log.w(TAG, "Didn't find matching message record..."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java new file mode 100644 index 0000000000..8ee10c908a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -0,0 +1,212 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessagingDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +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.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class RemoteDeleteSendJob extends BaseJob { + + public static final String KEY = "RemoteDeleteSendJob"; + + private static final String TAG = Log.tag(RemoteDeleteSendJob.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_IS_MMS = "is_mms"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final long messageId; + private final boolean isMms; + private final List recipients; + private final int initialRecipientCount; + + + @WorkerThread + public static @NonNull RemoteDeleteSendJob create(@NonNull Context context, + long messageId, + boolean isMms) + throws NoSuchMessageException + { + MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId) + : DatabaseFactory.getSmsDatabase(context).getMessage(messageId); + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List recipients = conversationRecipient.isGroup() ? Stream.of(conversationRecipient.getParticipants()).map(Recipient::getId).toList() + : Stream.of(conversationRecipient.getId()).toList(); + + recipients.remove(Recipient.self().getId()); + + return new RemoteDeleteSendJob(messageId, + isMms, + recipients, + recipients.size(), + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private RemoteDeleteSendJob(long messageId, + boolean isMms, + @NonNull List recipients, + int initialRecipientCount, + @NonNull Parameters parameters) + { + super(parameters); + + this.messageId = messageId; + this.isMms = isMms; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putBoolean(KEY_IS_MMS, isMms) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + MessagingDatabase db; + MessageRecord message; + + if (isMms) { + db = DatabaseFactory.getMmsDatabase(context); + message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } else { + db = DatabaseFactory.getSmsDatabase(context); + message = DatabaseFactory.getSmsDatabase(context).getMessage(messageId); + } + + long targetSentTimestamp = message.getDateSent(); + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + if (!message.isOutgoing()) { + throw new IllegalStateException("Cannot delete a message that isn't yours!"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations, targetSentTimestamp); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (recipients.isEmpty()) { + db.markAsSent(messageId, true); + } else { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send the reaction to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") ); + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, long targetSentTimestamp) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = Stream.of(destinations).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); + List> unidentifiedAccess = Stream.of(destinations).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList(); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withRemoteDelete(new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp)); + + if (conversationRecipient.isGroup()) { + dataMessage.asGroupMessage(new SignalServiceGroup(conversationRecipient.requireGroupId().getDecodedId())); + } + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + Stream.of(results) + .filter(r -> r.getIdentityFailure() != null) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Identity failure for " + r.getId())); + + Stream.of(results) + .filter(SendMessageResult::isUnregisteredFailure) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId())); + + return Stream.of(results) + .filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure()) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .toList(); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull RemoteDeleteSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + long messageId = data.getLong(KEY_MESSAGE_ID); + boolean isMms = data.getBoolean(KEY_IS_MMS); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + + return new RemoteDeleteSendJob(messageId, isMms, recipients, initialRecipientCount, parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java index 8683a6af4a..8dd7450554 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -527,6 +527,8 @@ public class MessageNotifier { slideDeck = ((MmsMessageRecord) record).getSlideDeck(); } else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record))); + } else if (record.isRemoteDelete()) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_this_message_was_deleted));; } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); slideDeck = ((MediaMmsMessageRecord) record).getSlideDeck(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 505c664379..882891998d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushMediaSendJob; import org.thoughtcrime.securesms.jobs.PushTextSendJob; import org.thoughtcrime.securesms.jobs.ReactionSendJob; +import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MmsException; @@ -309,6 +310,19 @@ public class MessageSender { } } + public static void sendRemoteDelete(@NonNull Context context, long messageId, boolean isMms) { + MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + db.markAsRemoteDelete(messageId); + db.markAsSending(messageId); + + try { + ApplicationDependencies.getJobManager().add(RemoteDeleteSendJob.create(context, messageId, isMms)); + onMessageSent(); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring."); + } + } + public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) { if (!messageRecord.isMms()) throw new AssertionError("Not Group"); sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId, Collections.emptyList()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 12786642ed..4c94ead3bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -55,6 +55,7 @@ public final class FeatureFlags { private static final String PINS_MEGAPHONE_KILL_SWITCH = "android.pinsMegaphoneKillSwitch"; private static final String PROFILE_NAMES_MEGAPHONE = "android.profileNamesMegaphone"; private static final String ATTACHMENTS_V3 = "android.attachmentsV3"; + private static final String REMOTE_DELETE = "android.remoteDelete"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -68,7 +69,8 @@ public final class FeatureFlags { PINS_MEGAPHONE_KILL_SWITCH, PROFILE_NAMES_MEGAPHONE, MESSAGE_REQUESTS, - ATTACHMENTS_V3 + ATTACHMENTS_V3, + REMOTE_DELETE ); /** @@ -218,6 +220,11 @@ public final class FeatureFlags { return getValue(ATTACHMENTS_V3, false); } + /** Send support for remotely deleting a message. */ + public static boolean remoteDelete() { + return getValue(REMOTE_DELETE, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java new file mode 100644 index 0000000000..41b8ced9a1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +public final class RemoteDeleteUtil { + + private static final long RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1); + private static final long SEND_THRESHOLD = TimeUnit.MINUTES.toMillis(30); + + private RemoteDeleteUtil() {} + + public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull Recipient deleteSender, long deleteServerTimestamp) { + boolean isValidSender = (deleteSender.isLocalNumber() && targetMessage.isOutgoing()) || + (!deleteSender.isLocalNumber() && !targetMessage.isOutgoing()); + + return isValidSender && + targetMessage.getIndividualRecipient().equals(deleteSender) && + (deleteServerTimestamp - targetMessage.getServerTimestamp()) < RECEIVE_THRESHOLD; + } + + public static boolean isValidSend(@NonNull Collection targetMessages, long currentTime) { + // TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages + return Stream.of(targetMessages) + .allMatch(message -> message.isOutgoing() && + !message.isRemoteDelete() && + !message.isPending() && + (currentTime - message.getDateSent()) < SEND_THRESHOLD); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78182e2b73..71402358c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,7 @@   Read More   Download More   Pending + This message was deleted. Reset secure session? @@ -298,6 +299,8 @@ SMS Deleting Deleting messages… + Delete for me + Delete for everyone Original message not found Original message no longer available Failed to open message @@ -959,7 +962,7 @@ %s reset the secure session. Duplicate message. This message could not be processed because it was sent from a newer version of Signal. You can ask your contact to send this message again after you update. - Error handling incoming message + Error handling incoming message. Stickers @@ -1006,6 +1009,7 @@ View-once photo View-once video View-once media + This message was deleted. %s is on Signal! Disappearing messages disabled Disappearing message time set to %s @@ -1131,6 +1135,7 @@ Reacted %1$s to your view-once photo. Reacted %1$s to your view-once video. Reacted %1$s to your sticker. + This message was deleted. Default 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 dc4fb6cba0..2eace93e33 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 @@ -639,6 +639,13 @@ public class SignalServiceMessageSender { builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.REACTIONS_VALUE, builder.getRequiredProtocolVersion())); } + if (message.getRemoteDelete().isPresent()) { + DataMessage.Delete delete = DataMessage.Delete.newBuilder() + .setTargetSentTimestamp(message.getRemoteDelete().get().getTargetSentTimestamp()) + .build(); + builder.setDelete(delete); + } + builder.setTimestamp(message.getTimestamp()); return container.setDataMessage(builder).build().toByteArray(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index dc80b5587a..2104861f7d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -293,6 +293,7 @@ public final class SignalServiceContent { List previews = createPreviews(content); SignalServiceDataMessage.Sticker sticker = createSticker(content); SignalServiceDataMessage.Reaction reaction = createReaction(content); + SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content); if (content.getRequiredProtocolVersion() > SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE) { throw new UnsupportedDataMessageProtocolVersionException(SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE, @@ -326,7 +327,8 @@ public final class SignalServiceContent { previews, sticker, content.getIsViewOnce(), - reaction); + reaction, + remoteDelete); } private static SignalServiceSyncMessage createSynchronizeMessage(SignalServiceMetadata metadata, @@ -660,6 +662,16 @@ public final class SignalServiceContent { reaction.getTargetSentTimestamp()); } + private static SignalServiceDataMessage.RemoteDelete createRemoteDelete(SignalServiceProtos.DataMessage content) { + if (!content.hasDelete() || !content.getDelete().hasTargetSentTimestamp()) { + return null; + } + + SignalServiceProtos.DataMessage.Delete delete = content.getDelete(); + + return new SignalServiceDataMessage.RemoteDelete(delete.getTargetSentTimestamp()); + } + private static List createSharedContacts(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException { if (content.getContactCount() <= 0) return null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java index 81442fc1ff..16601935f5 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -35,6 +35,7 @@ public class SignalServiceDataMessage { private final Optional sticker; private final boolean viewOnce; private final Optional reaction; + private final Optional remoteDelete; /** * Construct a SignalServiceDataMessage. @@ -53,7 +54,7 @@ public class SignalServiceDataMessage { String body, boolean endSession, int expiresInSeconds, boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, Quote quote, List sharedContacts, List previews, - Sticker sticker, boolean viewOnce, Reaction reaction) + Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete) { try { this.group = SignalServiceGroupContext.createOptional(group, groupV2); @@ -72,6 +73,7 @@ public class SignalServiceDataMessage { this.sticker = Optional.fromNullable(sticker); this.viewOnce = viewOnce; this.reaction = Optional.fromNullable(reaction); + this.remoteDelete = Optional.fromNullable(remoteDelete); if (attachments != null && !attachments.isEmpty()) { this.attachments = Optional.of(attachments); @@ -174,6 +176,10 @@ public class SignalServiceDataMessage { return reaction; } + public Optional getRemoteDelete() { + return remoteDelete; + } + public static class Builder { private List attachments = new LinkedList<>(); @@ -193,6 +199,7 @@ public class SignalServiceDataMessage { private Sticker sticker; private boolean viewOnce; private Reaction reaction; + private RemoteDelete remoteDelete; private Builder() {} @@ -300,12 +307,17 @@ public class SignalServiceDataMessage { return this; } + public Builder withRemoteDelete(RemoteDelete remoteDelete) { + this.remoteDelete = remoteDelete; + return this; + } + public SignalServiceDataMessage build() { if (timestamp == 0) timestamp = System.currentTimeMillis(); return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession, expiresInSeconds, expirationUpdate, profileKey, profileKeyUpdate, quote, sharedContacts, previews, - sticker, viewOnce, reaction); + sticker, viewOnce, reaction, remoteDelete); } } @@ -446,4 +458,16 @@ public class SignalServiceDataMessage { return targetSentTimestamp; } } + + public static class RemoteDelete { + private final long targetSentTimestamp; + + public RemoteDelete(long targetSentTimestamp) { + this.targetSentTimestamp = targetSentTimestamp; + } + + public long getTargetSentTimestamp() { + return targetSentTimestamp; + } + } } diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index e1afb82c60..80a0544a98 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -184,6 +184,10 @@ message DataMessage { optional uint64 targetSentTimestamp = 5; } + message Delete { + optional uint64 targetSentTimestamp = 1; + } + enum ProtocolVersion { option allow_alias = true; @@ -211,6 +215,7 @@ message DataMessage { optional uint32 requiredProtocolVersion = 12; optional bool isViewOnce = 14; optional Reaction reaction = 16; + optional Delete delete = 17; } message NullMessage {