diff --git a/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_00.binproto b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_00.binproto new file mode 100644 index 0000000000..49f24ba346 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_00.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_01.binproto b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_01.binproto new file mode 100644 index 0000000000..06b263c663 Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_01.binproto differ diff --git a/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_02.binproto b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_02.binproto new file mode 100644 index 0000000000..a4f994f56c Binary files /dev/null and b/app/src/androidTest/assets/backupTests/chat_item_admin_deleted_02.binproto differ diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt index fb831a9e19..6f8056f64f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt @@ -71,6 +71,11 @@ class ArchiveImportExportTests { runTests { it.startsWith("chat_folder_") } } +// @Test + fun chatItemAdminDelete() { + runTests { it.startsWith("chat_item_admin_deleted_") } + } + // @Test fun chatItemContactMessage() { runTests { it.startsWith("chat_item_contact_message_") } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendTableTest.kt index 84fe56b5b2..dc290b2d92 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendTableTest.kt @@ -190,7 +190,7 @@ class StorySendTableTest { @Test fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() { storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) - SignalDatabase.messages.markAsRemoteDelete(messageId1) + SignalDatabase.messages.markAsDeleteBySelf(messageId1) storySends.insert(messageId2, recipients6to15, 200, true, distributionId2) @@ -287,7 +287,7 @@ class StorySendTableTest { fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() { storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) - SignalDatabase.messages.markAsRemoteDelete(messageId1) + SignalDatabase.messages.markAsDeleteBySelf(messageId1) val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!! diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index cdef9e883e..ac21029380 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -105,7 +105,6 @@ class ConversationElementGenerator { false, emptyList(), false, - false, now, true, now, @@ -122,6 +121,7 @@ class ConversationElementGenerator { 0, false, 0, + null, null ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveErrorCases.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveErrorCases.kt index e6ee2eb992..583060c4d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveErrorCases.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveErrorCases.kt @@ -283,6 +283,10 @@ object ImportSkips { return log(0, "Missing recipient for chat $chatId") } + fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String { + return log(sentTimestamp, "Missing admin delete recipient for chat $chatId") + } + private fun log(sentTimestamp: Long, message: String): String { return "[SKIP][$sentTimestamp] $message" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableArchiveExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableArchiveExtensions.kt index 4e8a7e14da..3a0f20b00a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableArchiveExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableArchiveExtensions.kt @@ -44,7 +44,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self ${MessageTable.FROM_RECIPIENT_ID}, ${MessageTable.TO_RECIPIENT_ID}, ${MessageTable.EXPIRE_STARTED}, - ${MessageTable.REMOTE_DELETED}, ${MessageTable.UNIDENTIFIED}, ${MessageTable.LINK_PREVIEWS}, ${MessageTable.SHARED_CONTACTS}, @@ -68,7 +67,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self ${MessageTable.VIEW_ONCE}, ${MessageTable.PINNED_UNTIL}, ${MessageTable.PINNING_MESSAGE_ID}, - ${MessageTable.PINNED_AT} + ${MessageTable.PINNED_AT}, + ${MessageTable.DELETED_BY} ) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 """.trimMargin() @@ -136,7 +136,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self MessageTable.TO_RECIPIENT_ID, EXPIRES_IN, MessageTable.EXPIRE_STARTED, - MessageTable.REMOTE_DELETED, MessageTable.UNIDENTIFIED, MessageTable.LINK_PREVIEWS, MessageTable.SHARED_CONTACTS, @@ -161,7 +160,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self PARENT_STORY_ID, MessageTable.PINNED_UNTIL, MessageTable.PINNING_MESSAGE_ID, - MessageTable.PINNED_AT + MessageTable.PINNED_AT, + MessageTable.DELETED_BY ) .from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex") .where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery") diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt index 2807e1f6e4..16565df240 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExporter.kt @@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil import org.signal.core.util.bytes import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.emptyIfNull -import org.signal.core.util.isEmpty import org.signal.core.util.isNotEmpty import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.kibiBytes @@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode import org.thoughtcrime.securesms.backup.v2.ExportOddities import org.thoughtcrime.securesms.backup.v2.ExportSkips import org.thoughtcrime.securesms.backup.v2.ExportState +import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment @@ -193,11 +193,16 @@ class ChatItemArchiveExporter( } when { - record.remoteDeleted -> { + record.deletedBy == record.fromRecipientId -> { builder.remoteDeletedMessage = RemoteDeletedMessage() transformTimer.emit("remote-delete") } + record.deletedBy != null -> { + builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy) + transformTimer.emit("admin-delete") + } + MessageTypes.isJoinedType(record.type) -> { builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL) transformTimer.emit("simple-update") @@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien } val direction = when { - record.type.isDirectionlessType() && !record.remoteDeleted -> { + record.type.isDirectionlessType() && record.deletedBy == null -> { Direction.DIRECTIONLESS } MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> { @@ -1662,7 +1667,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId: this.giftBadge == null && this.viewOnceMessage == null && this.directStoryReplyMessage == null && - this.poll == null + this.poll == null && + this.adminDeletedMessage == null ) { Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent)) return null @@ -1805,7 +1811,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set, backupStartTime: Lo toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID), expiresIn = expiresIn, expireStarted = expireStarted, - remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED), sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED), linkPreview = this.requireString(MessageTable.LINK_PREVIEWS), sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS), @@ -1830,6 +1835,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set, backupStartTime: Lo parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID), pinnedAt = this.requireLong(MessageTable.PINNED_AT), pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL), + deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY), messageExtrasSize = messageExtras?.size ?: 0 ) } @@ -1847,7 +1853,6 @@ private class BackupMessageRecord( val toRecipientId: Long, val expiresIn: Long, val expireStarted: Long, - val remoteDeleted: Boolean, val sealedSender: Boolean, val linkPreview: String?, val sharedContacts: String?, @@ -1872,6 +1877,7 @@ private class BackupMessageRecord( val viewOnce: Boolean, val pinnedAt: Long, val pinnedUntil: Long, + val deletedBy: Long?, private val messageExtrasSize: Int ) { val estimatedSizeInBytes: Int = (body?.length ?: 0) + diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt index aa04260860..6af0812cae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/importer/ChatItemArchiveImporter.kt @@ -121,7 +121,6 @@ class ChatItemArchiveImporter( MessageTable.EXPIRES_IN, MessageTable.EXPIRE_STARTED, MessageTable.UNIDENTIFIED, - MessageTable.REMOTE_DELETED, MessageTable.NETWORK_FAILURES, MessageTable.QUOTE_ID, MessageTable.QUOTE_AUTHOR, @@ -141,7 +140,8 @@ class ChatItemArchiveImporter( MessageTable.NOTIFIED, MessageTable.PINNED_UNTIL, MessageTable.PINNING_MESSAGE_ID, - MessageTable.PINNED_AT + MessageTable.PINNED_AT, + MessageTable.DELETED_BY ) private val REACTION_COLUMNS = arrayOf( @@ -193,6 +193,12 @@ class ChatItemArchiveImporter( Log.w(TAG, ImportSkips.chatIdRemoteRecipientNotFound(chatItem.dateSent, chatItem.chatId)) return } + + if (chatItem.adminDeletedMessage != null && importState.remoteToLocalRecipientId[chatItem.adminDeletedMessage.adminId] == null) { + Log.w(TAG, ImportSkips.missingAdminDeleteRecipient(chatItem.dateSent, chatItem.chatId)) + return + } + val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId) if (chatItem.revisions.isNotEmpty()) { // Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure @@ -672,7 +678,6 @@ class ChatItemArchiveImporter( contentValues.put(MessageTable.QUOTE_MISSING, 0) contentValues.put(MessageTable.QUOTE_TYPE, 0) contentValues.put(MessageTable.VIEW_ONCE, 0) - contentValues.put(MessageTable.REMOTE_DELETED, 0) contentValues.put(MessageTable.PARENT_STORY_ID, 0) if (this.pinDetails != null) { @@ -683,12 +688,13 @@ class ChatItemArchiveImporter( when { this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage) - this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1) + this.remoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong()) this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage, fromRecipientId, toRecipientId) this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId) this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge) this.viewOnceMessage != null -> contentValues.addViewOnce(this.viewOnceMessage) this.directStoryReplyMessage != null -> contentValues.addDirectStoryReply(this.directStoryReplyMessage, toRecipientId) + this.adminDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, importState.remoteToLocalRecipientId[this.adminDeletedMessage.adminId]!!.toLong()) } return contentValues 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 9bdaa6e863..a700b26781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.events.PartProgressEvent; +import org.thoughtcrime.securesms.fonts.SignalSymbols; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob; @@ -1093,16 +1094,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setOverflowText(null); bodyText.setMaxLength(-1); - if (messageRecord.isRemoteDelete()) { - String deletedMessage = context.getString(messageRecord.isOutgoing() ? R.string.ConversationItem_you_deleted_this_message : R.string.ConversationItem_this_message_was_deleted); - SpannableString italics = new SpannableString(deletedMessage); - italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - italics.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, R.color.signal_text_primary)), - 0, - deletedMessage.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - bodyText.setText(italics); + if (conversationMessage.getDeletedByRecipient() != null) { + bodyText.setText(getDeletedMessageText(conversationMessage)); bodyText.setVisibility(View.VISIBLE); bodyText.setOverflowText(null); } else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord) || messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) { @@ -1156,6 +1149,44 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private SpannableStringBuilder getDeletedMessageText(@NonNull ConversationMessage message) { + boolean isAdminDelete = !message.getDeletedByRecipient().equals(message.getMessageRecord().getFromRecipient()); + CharSequence body; + + if (!isAdminDelete && messageRecord.isOutgoing()) { + body = formatDeletedText(context.getString(R.string.ConversationItem_you_deleted_this_message)); + } else if (!isAdminDelete) { + body = formatDeletedText(context.getString(R.string.ConversationItem_this_message_was_deleted)); + } else { + SpannableString prefix = formatDeletedText(context.getString(R.string.ConversationItem_admin)); + SpannableString suffix = formatDeletedText(context.getString(R.string.ConversationItem_deleted_this_message)); + + int nameColor = colorizer.getIncomingGroupSenderColor(getContext(), message.getDeletedByRecipient()); + SpannableString name = new SpannableString(message.getDeletedByRecipient().getDisplayName(context)); + name.setSpan(new ForegroundColorSpan(nameColor), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + name.setSpan(new RecipientClickableSpan(conversationMessage.getDeletedByRecipient().getId()), 0, name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + name.setSpan(new StyleSpan(Typeface.BOLD), 0, name.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + body = new SpannableStringBuilder() + .append(prefix) + .append(" ") + .append(name) + .append(" ") + .append(suffix); + } + + return new SpannableStringBuilder() + .append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.X_CIRCLE, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)) + .append(" ") + .append(body); + } + + private SpannableString formatDeletedText(String text) { + SpannableString spannableString = new SpannableString(text); + spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(context, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannableString; + } + private void setMediaAttributes(@NonNull MessageRecord messageRecord, @NonNull Optional previousRecord, @NonNull Optional nextRecord, @@ -1668,7 +1699,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo List mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody); for (Annotation annotation : mentionAnnotations) { - messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + messageBody.setSpan(new RecipientClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } @@ -2895,18 +2926,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } - private class MentionClickableSpan extends ClickableSpan { - private final RecipientId mentionedRecipientId; + private class RecipientClickableSpan extends ClickableSpan { + private final RecipientId recipientId; - MentionClickableSpan(RecipientId mentionedRecipientId) { - this.mentionedRecipientId = mentionedRecipientId; + RecipientClickableSpan(RecipientId recipientId) { + this.recipientId = recipientId; } @Override public void onClick(@NonNull View widget) { if (eventListener != null && batchSelected.isEmpty()) { VibrateUtil.vibrateTick(context); - eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId()); + eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index faa64b0220..1983d56479 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -51,6 +51,7 @@ public class ConversationMessage { @NonNull private final ComputedProperties computedProperties; @Nullable private final MemberLabel memberLabel; @Nullable private final MemberLabel quoteMemberLabel; + @Nullable private final Recipient deletedByRecipient; private ConversationMessage(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @@ -61,7 +62,8 @@ public class ConversationMessage { @Nullable MessageRecord originalMessage, @NonNull ComputedProperties computedProperties, @Nullable MemberLabel memberLabel, - @Nullable MemberLabel quoteMemberLabel) + @Nullable MemberLabel quoteMemberLabel, + @Nullable Recipient deletedByRecipient) { this.messageRecord = messageRecord; this.hasBeenQuoted = hasBeenQuoted; @@ -72,6 +74,7 @@ public class ConversationMessage { this.computedProperties = computedProperties; this.memberLabel = memberLabel; this.quoteMemberLabel = quoteMemberLabel; + this.deletedByRecipient = deletedByRecipient; if (body != null) { this.body = SpannableString.valueOf(body); @@ -116,6 +119,10 @@ public class ConversationMessage { return quoteMemberLabel; } + public @Nullable Recipient getDeletedByRecipient() { + return deletedByRecipient; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -252,6 +259,7 @@ public class ConversationMessage { FormattedDate formattedDate = getFormattedDate(context, messageRecord); MemberLabel memberLabel = getMemberLabel(messageRecord, threadRecipient); MemberLabel quoteMemberLabel = getQuoteMemberLabel(messageRecord, threadRecipient); + Recipient deletedBy = messageRecord.getDeletedBy() != null ? Recipient.resolved(messageRecord.getDeletedBy()) : null; return new ConversationMessage(messageRecord, styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body, @@ -262,7 +270,8 @@ public class ConversationMessage { originalMessage, new ComputedProperties(formattedDate), memberLabel, - quoteMemberLabel); + quoteMemberLabel, + deletedBy); } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index d6fa7bf7ec..51748f5769 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -2828,7 +2828,10 @@ class ConversationFragment : disposables += DeleteDialog.show( context = requireContext(), - messageRecords = records + messageRecords = records, + title = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_title, records.size, records.size), + message = requireContext().resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_body, records.size, records.size), + isAdmin = conversationGroupViewModel.isAdmin() ).observeOn(AndroidSchedulers.mainThread()) .subscribe { (deleted: Boolean, _: Boolean) -> if (!deleted) return@subscribe diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt index 172b513c58..9a97f8c66d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt @@ -62,6 +62,10 @@ class ConversationGroupViewModel( disposables.clear() } + fun isAdmin(): Boolean { + return _memberLevel.value?.groupTableMemberLevel == GroupTable.MemberLevel.ADMINISTRATOR + } + fun isNonAdminInAnnouncementGroup(): Boolean { val memberLevel = _memberLevel.value ?: return false return memberLevel.groupTableMemberLevel != GroupTable.MemberLevel.ADMINISTRATOR && memberLevel.isAnnouncementGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index ebe657d060..477d503955 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -534,7 +534,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind } else { alertView.setNone(); - if (thread.getExtra() != null && thread.getExtra().isRemoteDelete()) { + if (thread.getExtra() != null && thread.getExtra().getDeletedBy() != null) { if (thread.isPending()) { deliveryStatusIndicator.setPending(); } else { @@ -697,8 +697,14 @@ public final class ConversationListItem extends ConstraintLayout implements Bind ThreadTable.Extra extra = thread.getExtra(); if (extra != null && extra.isViewOnce()) { return emphasisAdded(context, getViewOnceDescription(context, thread.getContentType()), defaultTint); - } else if (extra != null && extra.isRemoteDelete()) { - return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint); + } else if (extra != null && extra.getDeletedBy() != null) { + RecipientId individualRecipientId = thread.getIndividualRecipientId(); + RecipientId deletedBy = thread.getDeletedByRecipientId(); + if (individualRecipientId.equals(deletedBy)) { + return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint); + } else { + return emphasisAdded(recipientToStringAsync(deletedBy, r -> new SpannableString(context.getString(R.string.ThreadRecord_admin_deleted_this_message, r.getDisplayName(context))))); + } } else if (extra != null && extra.isPoll()) { return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index fc07320476..e7bfd99a8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -188,7 +188,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val UNIDENTIFIED = "unidentified" const val REACTIONS_UNREAD = "reactions_unread" const val REACTIONS_LAST_SEEN = "reactions_last_seen" - const val REMOTE_DELETED = "remote_deleted" const val SERVER_GUID = "server_guid" const val RECEIPT_TIMESTAMP = "receipt_timestamp" const val EXPORT_STATE = "export_state" @@ -223,6 +222,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val PINNED_UNTIL = "pinned_until" const val PINNING_MESSAGE_ID = "pinning_message_id" const val PINNED_AT = "pinned_at" + const val DELETED_BY = "deleted_by" const val QUOTE_NOT_PRESENT_ID = 0L const val QUOTE_TARGET_MISSING_ID = -1L @@ -273,7 +273,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $VIEW_ONCE INTEGER DEFAULT 0, $REACTIONS_UNREAD INTEGER DEFAULT 0, $REACTIONS_LAST_SEEN INTEGER DEFAULT -1, - $REMOTE_DELETED INTEGER DEFAULT 0, $MENTIONS_SELF INTEGER DEFAULT 0, $NOTIFIED_TIMESTAMP INTEGER DEFAULT 0, $SERVER_GUID TEXT DEFAULT NULL, @@ -292,7 +291,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $VOTES_LAST_SEEN INTEGER DEFAULT 0, $PINNED_UNTIL INTEGER DEFAULT 0, $PINNING_MESSAGE_ID INTEGER DEFAULT 0, - $PINNED_AT INTEGER DEFAULT 0 + $PINNED_AT INTEGER DEFAULT 0, + $DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE ) """ @@ -325,7 +325,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0", "CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)", "CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)", - "CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)" + "CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)", + "CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)" ) private val MMS_PROJECTION_BASE = arrayOf( @@ -366,7 +367,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat VIEW_ONCE, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, - REMOTE_DELETED, MENTIONS_SELF, NOTIFIED_TIMESTAMP, VIEWED_COLUMN, @@ -381,7 +381,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat MESSAGE_EXTRAS, VOTES_UNREAD, VOTES_LAST_SEEN, - PINNED_UNTIL + PINNED_UNTIL, + DELETED_BY ) private val MMS_PROJECTION: Array = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}" @@ -427,7 +428,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS} """.toSingleLine() - private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $REMOTE_DELETED = 0" + private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" private const val RAW_ID_WHERE = "$TABLE_NAME.$ID = ?" private val SNIPPET_QUERY = @@ -1600,7 +1601,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} WHERE $STORY_TYPE > 0 AND - $REMOTE_DELETED = 0 + $DELETED_BY IS NULL ${if (isOutgoingOnly) " AND is_outgoing != 0" else ""} ORDER BY is_unread DESC, @@ -2218,12 +2219,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat AppDependencies.databaseObserver.notifyConversationListListeners() } - fun markAsRemoteDelete(targetMessage: MessageRecord) { + fun markAsRemoteDelete(targetMessage: MessageRecord, deletedBy: RecipientId) { writableDatabase.withinTransaction { db -> val hasRevision = (targetMessage as? MmsMessageRecord)?.latestRevisionId?.id != null if (hasRevision || targetMessage.isEditMessage) { val latestRevisionId = (targetMessage as? MmsMessageRecord)?.latestRevisionId?.id ?: targetMessage.id - markAsRemoteDeleteInternal(latestRevisionId) + markAsRemoteDeleteInternal(latestRevisionId, deletedBy) getPreviousEditIds(latestRevisionId).map { id -> db.update(TABLE_NAME) .values( @@ -2235,22 +2236,22 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat deleteMessage(id) } } else { - markAsRemoteDeleteInternal(targetMessage.id) + markAsRemoteDeleteInternal(targetMessage.id, deletedBy) } } } - fun markAsRemoteDelete(messageId: Long) { + fun markAsDeleteBySelf(messageId: Long) { val targetMessage: MessageRecord = getMessageRecord(messageId) - markAsRemoteDelete(targetMessage) + markAsRemoteDelete(targetMessage, Recipient.self().id) } - private fun markAsRemoteDeleteInternal(messageId: Long) { + private fun markAsRemoteDeleteInternal(messageId: Long, deletedBy: RecipientId) { var deletedAttachments = false writableDatabase.withinTransaction { db -> db.update(TABLE_NAME) .values( - REMOTE_DELETED to 1, + DELETED_BY to deletedBy.toLong(), BODY to null, QUOTE_BODY to null, QUOTE_AUTHOR to null, @@ -4012,7 +4013,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } fun deleteRemotelyDeletedStory(messageId: Long) { - if (readableDatabase.exists(TABLE_NAME).where("$ID = ? AND $REMOTE_DELETED = ?", messageId, 1).run()) { + if (readableDatabase.exists(TABLE_NAME).where("$ID = ? AND $DELETED_BY > 0", messageId).run()) { deleteMessage(messageId) } else { Log.i(TAG, "Unable to delete remotely deleted story: $messageId") @@ -4595,7 +4596,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val targetMessageDateReceived: Long = readableDatabase .select(DATE_RECEIVED, LATEST_REVISION_ID) .from(TABLE_NAME) - .where("$DATE_SENT = $quoteId AND $FROM_RECIPIENT_ID = ? AND $REMOTE_DELETED = 0 AND $SCHEDULED_DATE = -1", authorId) + .where("$DATE_SENT = $quoteId AND $FROM_RECIPIENT_ID = ? AND $DELETED_BY IS NULL AND $SCHEDULED_DATE = -1", authorId) .run() .readToSingleObject { cursor -> val latestRevisionId = cursor.requireLongOrNull(LATEST_REVISION_ID) @@ -4626,7 +4627,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat fun getMessagePositionInConversation(threadId: Long, receivedTimestamp: Long, authorId: RecipientId): Int { val validMessageExists: Boolean = readableDatabase .exists(TABLE_NAME) - .where("$DATE_RECEIVED = $receivedTimestamp AND $FROM_RECIPIENT_ID = ? AND $REMOTE_DELETED = 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL", authorId) + .where("$DATE_RECEIVED = $receivedTimestamp AND $FROM_RECIPIENT_ID = ? AND $DELETED_BY IS NULL AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL", authorId) .run() if (!validMessageExists) { @@ -5455,7 +5456,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .where("$QUOTE_AUTHOR = ?", fromId) .run() - Log.d(TAG, "Remapped $fromId to $toId. fromRecipient: $fromCount, toRecipient: $toCount, quoteAuthor: $quoteAuthorCount") + val deletedByCount = writableDatabase + .update(TABLE_NAME) + .values(DELETED_BY to toId.serialize()) + .where("$DELETED_BY = ?", fromId) + .run() + + Log.d(TAG, "Remapped $fromId to $toId. fromRecipient: $fromCount, toRecipient: $toCount, quoteAuthor: $quoteAuthorCount, deletedBy: $deletedByCount") } override fun remapThread(fromId: Long, toId: Long) { @@ -6255,7 +6262,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val expireTimerVersion = cursor.requireInt(EXPIRE_TIMER_VERSION) val unidentified = cursor.requireBoolean(UNIDENTIFIED) val isViewOnce = cursor.requireBoolean(VIEW_ONCE) - val remoteDelete = cursor.requireBoolean(REMOTE_DELETED) val mentionsSelf = cursor.requireBoolean(MENTIONS_SELF) val notifiedTimestamp = cursor.requireLong(NOTIFIED_TIMESTAMP) var isViewed = cursor.requireBoolean(VIEWED_COLUMN) @@ -6269,6 +6275,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val editCount = cursor.requireInt(REVISION_NUMBER) val isRead = cursor.requireBoolean(READ) val pinnedUntil = cursor.requireLong(PINNED_UNTIL) + val deletedBy = cursor.requireLongOrNull(DELETED_BY)?.let { RecipientId.from(it) } val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS) val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) } @@ -6346,7 +6353,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat previews, unidentified, emptyList(), - remoteDelete, mentionsSelf, notifiedTimestamp, isViewed, @@ -6364,6 +6370,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat editCount, isRead, pinnedUntil, + deletedBy, messageExtras ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt index 5cdd580837..508cfb52d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendTable.kt @@ -147,7 +147,7 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas AND $MESSAGE_ID IN ( SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME} - WHERE ${MessageTable.REMOTE_DELETED} = 0 + WHERE ${MessageTable.DELETED_BY} IS NULL ) ) """ @@ -232,7 +232,7 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas .where( """ $SENT_TIMESTAMP = ? AND - (SELECT ${MessageTable.REMOTE_DELETED} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.ID} = $MESSAGE_ID) = 0 + (SELECT ${MessageTable.DELETED_BY} FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.ID} = $MESSAGE_ID) IS NULL """, sentTimestamp ) @@ -330,7 +330,7 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas val messagesWithoutAnyReceivers = localRows.map { it.messageId }.distinct() - remoteRows.map { it.messageId }.distinct() messagesWithoutAnyReceivers.forEach { - SignalDatabase.messages.markAsRemoteDelete(it) + SignalDatabase.messages.markAsDeleteBySelf(it) SignalDatabase.messages.deleteRemotelyDeletedStory(it) } } @@ -344,7 +344,7 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas $TABLE_NAME.$RECIPIENT_ID, $ALLOWS_REPLIES, $DISTRIBUTION_ID, - ${MessageTable.REMOTE_DELETED} + ${MessageTable.DELETED_BY} FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID WHERE $TABLE_NAME.$SENT_TIMESTAMP = ? @@ -353,7 +353,7 @@ class StorySendTable(context: Context, databaseHelper: SignalDatabase) : Databas ).use { cursor -> val results: MutableMap = mutableMapOf() while (cursor.moveToNext()) { - val isRemoteDeleted = CursorUtil.requireBoolean(cursor, MessageTable.REMOTE_DELETED) + val isRemoteDeleted = !CursorUtil.isNull(cursor, MessageTable.DELETED_BY) val recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)) val distributionId = DistributionId.from(CursorUtil.requireString(cursor, DISTRIBUTION_ID)) val distributionIdList: List = if (isRemoteDeleted) emptyList() else listOf(distributionId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 80e0c09227..6e343f5e66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -2094,7 +2094,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val extras: Extra? = if (record.isScheduled()) { Extra.forScheduledMessage(authorId) } else if (record.isRemoteDelete) { - Extra.forRemoteDelete(authorId) + Extra.forRemoteDelete(authorId, record.deletedBy!!) } else if (record.isViewOnce) { Extra.forViewOnce(authorId) } else if (record.isMms && (record as MmsMessageRecord).slideDeck.stickerSlide != null) { @@ -2280,7 +2280,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa isSticker = jsonObject.getBoolean("isSticker"), stickerEmoji = jsonObject.getString("stickerEmoji"), isAlbum = jsonObject.getBoolean("isAlbum"), - isRemoteDelete = jsonObject.getBoolean("isRemoteDelete"), + deletedBy = jsonObject.getString("deletedBy"), isMessageRequestAccepted = jsonObject.getBoolean("isMessageRequestAccepted"), isGv2Invite = jsonObject.getBoolean("isGv2Invite"), groupAddedBy = jsonObject.getString("groupAddedBy"), @@ -2353,8 +2353,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa @param:JsonProperty("isAlbum") val isAlbum: Boolean = false, @field:JsonProperty - @param:JsonProperty("isRemoteDelete") - val isRemoteDelete: Boolean = false, + @param:JsonProperty("deletedBy") + val deletedBy: String? = null, @field:JsonProperty @param:JsonProperty("isMessageRequestAccepted") val isMessageRequestAccepted: Boolean = true, @@ -2398,8 +2398,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa return Extra(isAlbum = true, individualRecipientId = individualRecipient.serialize()) } - fun forRemoteDelete(individualRecipient: RecipientId): Extra { - return Extra(isRemoteDelete = true, individualRecipientId = individualRecipient.serialize()) + fun forRemoteDelete(individualRecipient: RecipientId, deletedBy: RecipientId): Extra { + return Extra(deletedBy = deletedBy.serialize(), individualRecipientId = individualRecipient.serialize()) } fun forMessageRequest(individualRecipient: RecipientId, isHidden: Boolean = false): Extra { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 5156806226..b9305b0d4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -155,6 +155,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V298_DoNotBackupRel import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentMetadataTable import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTransparencyColumn import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLinkEpoch +import org.thoughtcrime.securesms.database.helpers.migration.V302_AddDeletedByColumn import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -316,10 +317,11 @@ object SignalDatabaseMigrations { 298 to V298_DoNotBackupReleaseNotes, 299 to V299_AddAttachmentMetadataTable, 300 to V300_AddKeyTransparencyColumn, - 301 to V301_RemoveCallLinkEpoch + 301 to V301_RemoveCallLinkEpoch, + 302 to V302_AddDeletedByColumn ) - const val DATABASE_VERSION = 301 + const val DATABASE_VERSION = 302 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V302_AddDeletedByColumn.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V302_AddDeletedByColumn.kt new file mode 100644 index 0000000000..f167b976f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V302_AddDeletedByColumn.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.signal.core.util.Stopwatch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds column to messages to track who has deleted a given message + */ +@Suppress("ClassName") +object V302_AddDeletedByColumn : SignalDatabaseMigration { + + private val TAG = Log.tag(V302_AddDeletedByColumn::class.java) + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + val stopwatch = Stopwatch("migration", decimalPlaces = 2) + + db.execSQL("ALTER TABLE message ADD COLUMN deleted_by INTEGER DEFAULT NULL REFERENCES recipient (_id) ON DELETE CASCADE") + stopwatch.split("add-column") + + db.execSQL("UPDATE message SET deleted_by = from_recipient_id WHERE remote_deleted > 0") + stopwatch.split("copy-data") + + db.execSQL("ALTER TABLE message DROP COLUMN remote_deleted") + stopwatch.split("drop-column") + + db.execSQL("CREATE INDEX message_deleted_by_index ON message (deleted_by)") + stopwatch.split("create-index") + + stopwatch.stop(TAG) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index d510521d3d..539c61c1f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -53,13 +53,13 @@ public class InMemoryMessageRecord extends MessageRecord { false, false, Collections.emptyList(), - false, 0, false, -1, null, 0, 0, + null, null); } 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 aa99d0e264..97dd119ed1 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 @@ -108,12 +108,12 @@ public abstract class MessageRecord extends DisplayRecord { private final boolean unidentified; private final List reactions; private final long serverTimestamp; - private final boolean remoteDelete; private final long notifiedTimestamp; private final long receiptTimestamp; private final MessageId originalMessageId; private final int revisionNumber; private final long pinnedUntil; + private final RecipientId deletedBy; private final MessageExtras messageExtras; protected Boolean isJumboji = null; @@ -130,13 +130,13 @@ public abstract class MessageRecord extends DisplayRecord { boolean hasReadReceipt, boolean unidentified, @NonNull List reactions, - boolean remoteDelete, long notifiedTimestamp, boolean viewed, long receiptTimestamp, @Nullable MessageId originalMessageId, int revisionNumber, long pinnedUntil, + @Nullable RecipientId deletedBy, @Nullable MessageExtras messageExtras) { super(body, fromRecipient, toRecipient, dateSent, dateReceived, @@ -153,12 +153,12 @@ public abstract class MessageRecord extends DisplayRecord { this.unidentified = unidentified; this.reactions = reactions; this.serverTimestamp = dateServer; - this.remoteDelete = remoteDelete; this.notifiedTimestamp = notifiedTimestamp; this.receiptTimestamp = receiptTimestamp; this.originalMessageId = originalMessageId; this.revisionNumber = revisionNumber; this.pinnedUntil = pinnedUntil; + this.deletedBy = deletedBy; this.messageExtras = messageExtras; } @@ -785,6 +785,10 @@ public abstract class MessageRecord extends DisplayRecord { return pinnedUntil; } + public @Nullable RecipientId getDeletedBy() { + return deletedBy; + } + public boolean isInMemoryMessageRecord() { return false; } @@ -832,7 +836,7 @@ public abstract class MessageRecord extends DisplayRecord { } public boolean isRemoteDelete() { - return remoteDelete; + return deletedBy != null; } public @NonNull List getReactions() { 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 dd610ec7eb..8c2b1069f2 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 @@ -103,7 +103,6 @@ public class MmsMessageRecord extends MessageRecord { @NonNull List linkPreviews, boolean unidentified, @NonNull List reactions, - boolean remoteDelete, boolean mentionsSelf, long notifiedTimestamp, boolean viewed, @@ -121,12 +120,13 @@ public class MmsMessageRecord extends MessageRecord { int revisionNumber, boolean isRead, long pinnedUntil, + @Nullable RecipientId deletedBy, @Nullable MessageExtras messageExtras) { super(id, body, fromRecipient, fromDeviceId, toRecipient, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt, - unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, messageExtras); + unidentified, reactions, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, deletedBy, messageExtras); this.slideDeck = slideDeck; this.quote = quote; @@ -337,17 +337,17 @@ public class MmsMessageRecord extends MessageRecord { public @NonNull MmsMessageRecord withReactions(@NonNull List reactions) { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf, + hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } public @NonNull MmsMessageRecord withoutQuote() { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } public @NonNull MmsMessageRecord withAttachments(@NonNull List attachments) { @@ -367,34 +367,34 @@ public class MmsMessageRecord extends MessageRecord { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), slideDeck, getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } public @NonNull MmsMessageRecord withCall(@Nullable CallTable.Call call) { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) { return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), - hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, + hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras()); } private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { 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 dff9ac6166..b01e05a79c 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 @@ -209,6 +209,14 @@ public final class ThreadRecord { } } + public @NonNull RecipientId getDeletedByRecipientId() { + if (extra != null && extra.getDeletedBy() != null) { + return RecipientId.from(extra.getDeletedBy()); + } else { + return RecipientId.UNKNOWN; + } + } + public @NonNull RecipientId getGroupMessageSender() { RecipientId threadRecipientId = getRecipient().getId(); RecipientId individualRecipientId = getIndividualRecipientId(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt index e61fa356b0..e5151806db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt @@ -189,7 +189,7 @@ object SignalSymbols { VIEW_ONCE_DASH('\uE079'), VIEW_ONCE_VIEWED('\uE07A'), X('\u00D7'), - X_CIRCLE('\u2297'), + X_CIRCLE('\uE1EE'), X_SQUARE('\u2327'), REFRESH('\uE000'), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AdminDeleteSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AdminDeleteSendJob.kt new file mode 100644 index 0000000000..a6eb4b8376 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AdminDeleteSendJob.kt @@ -0,0 +1,184 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.models.ServiceId +import org.signal.core.util.logging.Log +import org.signal.core.util.logging.Log.tag +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.AdminDeleteJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messages.GroupSendUtil +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.util.GroupUtil +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.days + +/** + * Job used when an admin deletes a message in a group + */ +class AdminDeleteSendJob private constructor( + private val messageId: Long, + private val recipientIds: MutableList, + private val initialRecipientCount: Int, + parameters: Parameters +) : Job(parameters) { + + companion object { + const val KEY: String = "AdminDeleteSendJob" + + private val TAG = tag(AdminDeleteSendJob::class.java) + + @JvmStatic + fun create(messageId: Long): AdminDeleteSendJob? { + val message = SignalDatabase.messages.getMessageRecord(messageId) + val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + + if (conversationRecipient == null) { + return null + } + + val recipientIds = conversationRecipient.participantIds.map { it.toLong() }.toMutableList() + + return AdminDeleteSendJob( + messageId = messageId, + recipientIds = recipientIds, + initialRecipientCount = recipientIds.size, + parameters = Parameters.Builder() + .setQueue(conversationRecipient.id.toQueueKey()) + .setLifespan(1.days.inWholeMilliseconds) + .setMaxAttempts(Parameters.UNLIMITED) + .build() + ) + } + } + + override fun serialize(): ByteArray? { + return AdminDeleteJobData(messageId, recipientIds, initialRecipientCount).encode() + } + + override fun getFactoryKey(): String { + return KEY + } + + override fun run(): Result { + if (!SignalStore.account.isRegistered) { + Log.w(TAG, "Not registered. Skipping.") + return Result.failure() + } + + val message = SignalDatabase.messages.getMessageRecord(messageId) + if (!message.fromRecipient.hasServiceId) { + Log.w(TAG, "Missing service id for the target author.") + return Result.failure() + } + + val recipients = recipientIds.map { Recipient.resolved(RecipientId.from(it)) }.toMutableList() + val targetSentTimestamp = message.dateSent + val targetAuthor = message.fromRecipient.requireServiceId() + + val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + if (conversationRecipient == null) { + Log.w(TAG, "We have a message, but couldn't find the thread!") + return Result.failure() + } + + if (!conversationRecipient.isPushV2Group) { + Log.w(TAG, "Cannot admin delete in a non V2 group.") + return Result.failure() + } + + val groupRecord = SignalDatabase.groups.getGroup(conversationRecipient.requireGroupId()) + if (groupRecord.isEmpty || !groupRecord.get().isAdmin(Recipient.self())) { + Log.w(TAG, "Cannot delete because you are not an admin.") + return Result.failure() + } + + val eligible = RecipientUtil.getEligibleForSending(recipients.filter { it.hasServiceId }) + val skippedRecipients = recipients - eligible + val sendResult = deliver(conversationRecipient, eligible, targetAuthor, targetSentTimestamp) + + for (completion in sendResult.completed) { + recipientIds.remove(completion.id.toLong()) + } + + for (unregistered in sendResult.unregistered) { + SignalDatabase.recipients.markUnregistered(unregistered) + } + + for (recipient in skippedRecipients) { + recipientIds.remove(recipient.id.toLong()) + } + + Log.i(TAG, "Completed now: ${sendResult.completed.size} Skipped: ${skippedRecipients.size + sendResult.skipped.size} Remaining: ${recipientIds.size}") + + if (recipientIds.isEmpty()) { + return Result.success() + } else { + Log.w(TAG, "Still need to send to ${recipients.size} recipients. Retrying.") + return Result.retry(defaultBackoff()) + } + } + + override fun onFailure() { + Log.w(TAG, "Failed to send admin delete to all recipients! ${initialRecipientCount - recipientIds.size} / $initialRecipientCount") + } + + private fun deliver( + conversationRecipient: Recipient, + destinations: MutableList, + targetAuthor: ServiceId, + targetSentTimestamp: Long + ): GroupSendJobHelper.SendResult { + val dataMessageBuilder = newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withAdminDelete(SignalServiceDataMessage.AdminDelete(targetAuthor, targetSentTimestamp)) + + GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()) + + val nonSelfDestinations = destinations.filterNot { it.isSelf } + val includeSelf = destinations.size != nonSelfDestinations.size + + val dataMessage = dataMessageBuilder.build() + + val results = GroupSendUtil.sendResendableDataMessage( + context, + conversationRecipient.groupId.map { it.requireV2() }.getOrNull(), + null, + nonSelfDestinations, + false, + ContentHint.RESENDABLE, + MessageId(messageId), + dataMessage, + true, + false, + null, + null + ).toMutableList() + + if (includeSelf) { + results.add(AppDependencies.signalServiceMessageSender.sendSyncMessage(dataMessage)) + } + + return GroupSendJobHelper.getCompletedSends(destinations, results) + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): AdminDeleteSendJob { + val data = AdminDeleteJobData.ADAPTER.decode(serializedData!!) + + return AdminDeleteSendJob( + messageId = data.messageId, + recipientIds = data.recipientIds.toMutableList(), + initialRecipientCount = data.initialRecipientCount, + parameters = parameters + ) + } + } +} 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 785030c09d..e7fbef4c8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -127,6 +127,7 @@ public final class JobManagerFactories { public static Map getJobFactories(@NonNull Application application) { return new HashMap<>() {{ put(AccountConsistencyWorkerJob.KEY, new AccountConsistencyWorkerJob.Factory()); + put(AdminDeleteSendJob.KEY, new AdminDeleteSendJob.Factory()); put(AnalyzeDatabaseJob.KEY, new AnalyzeDatabaseJob.Factory()); put(ApkUpdateJob.KEY, new ApkUpdateJob.Factory()); put(ArchiveAttachmentBackfillJob.KEY, new ArchiveAttachmentBackfillJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index fdb3543828..d6d953189c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -185,6 +185,7 @@ object DataMessageProcessor { message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry) message.pinMessage != null -> insertResult = handlePinMessage(envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, earlyMessageCacheEntry) message.unpinMessage != null -> messageId = handleUnpinMessage(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry) + message.adminDelete != null -> messageId = handleAdminRemoteDelete(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry) } messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) } @@ -598,7 +599,7 @@ object DataMessageProcessor { val targetMessage: MessageRecord? = SignalDatabase.messages.getMessageFor(targetSentTimestamp, senderRecipientId) return if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipientId, envelope.serverTimestamp!!)) { - SignalDatabase.messages.markAsRemoteDelete(targetMessage) + SignalDatabase.messages.markAsRemoteDelete(targetMessage, senderRecipientId) if (targetMessage.isStory()) { SignalDatabase.messages.deleteRemotelyDeletedStory(targetMessage.id) } @@ -1406,6 +1407,48 @@ object DataMessageProcessor { return MessageId(targetMessageId) } + fun handleAdminRemoteDelete(envelope: Envelope, message: DataMessage, senderRecipient: Recipient, threadRecipient: Recipient, earlyMessageCacheEntry: EarlyMessageCacheEntry?): MessageId? { + if (!RemoteConfig.receiveAdminDelete) { + log(envelope.timestamp!!, "Admin delete is not allowed due to remote config.") + return null + } + + val delete = message.adminDelete!! + + log(envelope.timestamp!!, "Admin delete for message ${delete.targetSentTimestamp}") + + val targetSentTimestamp: Long = delete.targetSentTimestamp!! + val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(delete.targetAuthorAciBinary!!) + if (targetAuthorServiceId.isUnknown) { + warn(envelope.timestamp!!, "[handleAdminRemoteDelete] Invalid author.") + return null + } + val targetAuthor = Recipient.externalPush(targetAuthorServiceId) + + val targetMessage: MessageRecord? = SignalDatabase.messages.getMessageFor(targetSentTimestamp, targetAuthor.id) + + val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull() + if (groupRecord == null || !groupRecord.isV2Group) { + warn(envelope.timestamp!!, "[handleAdminRemoteDelete] Invalid group.") + return null + } + + return if (targetMessage != null && MessageConstraintsUtil.isValidAdminDeleteReceive(targetMessage, senderRecipient, envelope.serverTimestamp!!, groupRecord)) { + SignalDatabase.messages.markAsRemoteDelete(targetMessage, senderRecipient.id) + MessageId(targetMessage.id) + } else if (targetMessage == null) { + warn(envelope.timestamp!!, "[handleAdminRemoteDelete] Could not find matching message! timestamp: $targetSentTimestamp") + if (earlyMessageCacheEntry != null) { + AppDependencies.earlyMessageCache.store(targetAuthor.id, targetSentTimestamp, earlyMessageCacheEntry) + PushProcessEarlyMessagesJob.enqueue() + } + null + } else { + warn(envelope.timestamp!!, "[handleAdminRemoteDelete] Invalid admin delete! deleteTime: ${envelope.serverTimestamp!!}, targetTime: ${targetMessage.serverTimestamp}, deleteAuthor: ${senderRecipient.id}, targetAuthor: ${targetMessage.fromRecipient.id}, isAdmin: ${groupRecord.isAdmin(senderRecipient)}") + null + } + } + fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) { val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index e5c18b6c64..d2d9b767f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -130,6 +130,8 @@ open class MessageContentProcessor(private val context: Context) { getGroupRecipient(content.dataMessage?.groupV2, sender) } else if (content.editMessage?.dataMessage.hasGroupContext) { getGroupRecipient(content.editMessage?.dataMessage?.groupV2, sender) + } else if (content.syncMessage?.sent?.message.hasGroupContext) { + getGroupRecipient(content.syncMessage?.sent?.message?.groupV2, sender) } else { sender } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt index 52fe2f1678..a39a66c1f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt @@ -56,7 +56,8 @@ object SignalServiceProtoUtil { pollVote != null || pollTerminate != null || pinMessage != null || - unpinMessage != null + unpinMessage != null || + adminDelete != null } val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index 097a7e0839..a8a2c32226 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -258,6 +258,10 @@ object SyncMessageProcessor { DataMessageProcessor.handleUnpinMessage(envelope, dataMessage, senderRecipient, threadRecipient, earlyMessageCacheEntry) threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent)) } + dataMessage.adminDelete != null -> { + DataMessageProcessor.handleAdminRemoteDelete(envelope, dataMessage, senderRecipient, threadRecipient, earlyMessageCacheEntry) + threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent)) + } else -> threadId = handleSynchronizeSentTextMessage(sent, envelope.timestamp!!) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 742c34f95a..5056872a40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -193,9 +193,10 @@ public class ApplicationMigrations { static final int ATTACHMENT_HASH_BACKFILL_2 = 149; static final int SVR2_ENCLAVE_UPDATE_5 = 150; static final int STICKER_PACK_ADDITION_2 = 151; + static final int DELETED_BY_DB_MIGRATION = 152; } - public static final int CURRENT_VERSION = 151; + public static final int CURRENT_VERSION = 152; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -894,6 +895,10 @@ public class ApplicationMigrations { jobs.put(Version.STICKER_PACK_ADDITION_2, new StickerPackAddition2MigrationJob()); } + if (lastSeenVersion < Version.DELETED_BY_DB_MIGRATION) { + jobs.put(Version.DELETED_BY_DB_MIGRATION, new DatabaseMigrationJob()); + } + return jobs; } 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 0850b17348..29f16855d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.database.model.StoryType; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AdminDeleteSendJob; import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob; import org.thoughtcrime.securesms.jobs.AttachmentCopyJob; import org.thoughtcrime.securesms.jobs.AttachmentUploadJob; @@ -78,7 +79,6 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -507,7 +507,7 @@ public class MessageSender { public static void sendRemoteDelete(long messageId) { MessageTable db = SignalDatabase.messages(); - db.markAsRemoteDelete(messageId); + db.markAsDeleteBySelf(messageId); db.markAsSending(messageId); try { @@ -518,6 +518,17 @@ public class MessageSender { } } + public static void sendAdminDelete(long messageId) { + // TODO(michelle): Update with failure states + SignalDatabase.messages().markAsDeleteBySelf(messageId); + AdminDeleteSendJob job = AdminDeleteSendJob.create(messageId); + if (job != null) { + AppDependencies.getJobManager().add(job); + } else { + Log.w(TAG, "[sendAdminDelete] Could not create the admin delete job."); + } + } + public static void resendGroupMessage(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull Set filterRecipientIds) { if (!messageRecord.isMms()) throw new AssertionError("Not Group"); sendGroupPush(context, messageRecord.getToRecipient(), messageRecord.getId(), filterRecipientIds, Collections.emptyList()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt index 22d5cae133..8d252142af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -32,7 +32,8 @@ object DeleteDialog { messageRecords: Set, title: CharSequence? = null, message: CharSequence = context.resources.getQuantityString(R.plurals.ConversationFragment_delete_selected_messages, messageRecords.size, messageRecords.size), - forceRemoteDelete: Boolean = false + forceRemoteDelete: Boolean = false, + isAdmin: Boolean = false ): Single> = Single.create { emitter -> val builder = MaterialAlertDialogBuilder(context) @@ -43,7 +44,7 @@ object DeleteDialog { val isNoteToSelfDelete = isNoteToSelfDelete(messageRecords) if (forceRemoteDelete) { - builder.setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> deleteForEveryone(messageRecords, emitter) } + builder.setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> deleteForEveryone(messageRecords = messageRecords, isAdminDelete = false, emitter = emitter) } } else { val positiveButton = if (isNoteToSelfDelete) { R.string.ConversationFragment_delete @@ -58,7 +59,9 @@ object DeleteDialog { } if (MessageConstraintsUtil.isValidRemoteDeleteSend(messageRecords, System.currentTimeMillis()) && !isNoteToSelfDelete) { - builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> handleDeleteForEveryone(context, messageRecords, emitter) } + builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> handleDeleteForEveryone(context = context, messageRecords = messageRecords, isAdminDelete = false, emitter = emitter) } + } else if (MessageConstraintsUtil.isValidAdminDeleteSend(messageRecords, System.currentTimeMillis(), isAdmin)) { + builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> handleAdminDeleteForEveryone(context, messageRecords, emitter) } } } @@ -71,15 +74,27 @@ object DeleteDialog { return messageRecords.all { messageRecord: MessageRecord -> messageRecord.isOutgoing && messageRecord.toRecipient.isSelf } } - private fun handleDeleteForEveryone(context: Context, messageRecords: Set, emitter: SingleEmitter>) { + private fun handleAdminDeleteForEveryone(context: Context, messageRecords: Set, emitter: SingleEmitter>) { + MaterialAlertDialogBuilder(context) + .setTitle("${context.getString(R.string.ConversationFragment_delete_for_everyone_title)} - INTERNAL ONLY") + .setMessage(R.string.ConversationFragment_delete_for_everyone_body) + .setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> + handleDeleteForEveryone(context = context, messageRecords = messageRecords, isAdminDelete = true, emitter = emitter) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> emitter.onSuccess(Pair(false, false)) } + .setOnCancelListener { emitter.onSuccess(Pair(false, false)) } + .show() + } + + private fun handleDeleteForEveryone(context: Context, messageRecords: Set, isAdminDelete: Boolean, emitter: SingleEmitter>) { if (SignalStore.uiHints.hasConfirmedDeleteForEveryoneOnce()) { - deleteForEveryone(messageRecords, emitter) + deleteForEveryone(messageRecords, isAdminDelete, emitter) } else { MaterialAlertDialogBuilder(context) .setMessage(R.string.ConversationFragment_this_message_will_be_deleted_for_everyone_in_the_conversation) .setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> SignalStore.uiHints.markHasConfirmedDeleteForEveryoneOnce() - deleteForEveryone(messageRecords, emitter) + deleteForEveryone(messageRecords, isAdminDelete, emitter) } .setNegativeButton(android.R.string.cancel) { _, _ -> emitter.onSuccess(Pair(false, false)) } .setOnCancelListener { emitter.onSuccess(Pair(false, false)) } @@ -87,10 +102,14 @@ object DeleteDialog { } } - private fun deleteForEveryone(messageRecords: Set, emitter: SingleEmitter>) { + private fun deleteForEveryone(messageRecords: Set, isAdminDelete: Boolean, emitter: SingleEmitter>) { SignalExecutors.BOUNDED.execute { messageRecords.forEach { message -> - MessageSender.sendRemoteDelete(message.id) + if (isAdminDelete) { + MessageSender.sendAdminDelete(message.id) + } else { + MessageSender.sendRemoteDelete(message.id) + } } emitter.onSuccess(Pair(true, false)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt index 0244f669b5..42124681de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt @@ -1,10 +1,11 @@ package org.thoughtcrime.securesms.util import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds /** @@ -12,8 +13,10 @@ import kotlin.time.Duration.Companion.milliseconds * have strict time limits. */ object MessageConstraintsUtil { - private val RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(2) - private val SEND_THRESHOLD = TimeUnit.DAYS.toMillis(1) + private val SEND_THRESHOLD = RemoteConfig.regularDeleteThreshold.milliseconds.inWholeMilliseconds + private val RECEIVE_THRESHOLD = SEND_THRESHOLD + 1.days.inWholeMilliseconds + private val ADMIN_SEND_THRESHOLD = RemoteConfig.adminDeleteThreshold.milliseconds.inWholeMilliseconds + private val ADMIN_RECEIVE_THRESHOLD = ADMIN_SEND_THRESHOLD + 1.days.inWholeMilliseconds const val MAX_EDIT_COUNT = 10 @@ -31,6 +34,14 @@ object MessageConstraintsUtil { ((deleteServerTimestamp - messageTimestamp < RECEIVE_THRESHOLD) || (selfIsDeleteSender && targetMessage.isOutgoing)) } + @JvmStatic + fun isValidAdminDeleteReceive(targetMessage: MessageRecord, deleteSender: Recipient, deleteServerTimestamp: Long, groupRecord: GroupRecord): Boolean { + val isValidSender = groupRecord.isAdmin(deleteSender) + val messageTimestamp = targetMessage.dateSent + + return isValidSender && (deleteServerTimestamp - messageTimestamp < ADMIN_RECEIVE_THRESHOLD) + } + @JvmStatic fun isValidEditMessageReceive(targetMessage: MessageRecord, editSender: Recipient, editServerTimestamp: Long): Boolean { return isValidRemoteDeleteReceive(targetMessage, editSender.id, editServerTimestamp) @@ -42,6 +53,11 @@ object MessageConstraintsUtil { return targetMessages.all { isValidRemoteDeleteSend(it, currentTime) } } + @JvmStatic + fun isValidAdminDeleteSend(targetMessages: Collection, currentTime: Long, isAdmin: Boolean): Boolean { + return targetMessages.all { isValidAdminDeleteSend(it, currentTime, isAdmin) } + } + @JvmStatic fun isWithinMaxEdits(targetMessage: MessageRecord): Boolean { return targetMessage.revisionNumber < MAX_EDIT_COUNT @@ -94,6 +110,19 @@ object MessageConstraintsUtil { (currentTime - message.dateSent < SEND_THRESHOLD || message.toRecipient.isSelf) } + private fun isValidAdminDeleteSend(message: MessageRecord, currentTime: Long, isAdmin: Boolean): Boolean { + return RemoteConfig.sendAdminDelete && + isAdmin && + !message.isUpdate && + message.isPush && + (!message.toRecipient.isGroup || message.toRecipient.isActiveGroup) && + !message.isRemoteDelete && + !message.hasGiftBadge() && + !message.isPaymentNotification && + !message.isPaymentTombstone && + (currentTime - message.dateSent < ADMIN_SEND_THRESHOLD) + } + private fun isSelf(recipientId: RecipientId): Boolean { return Recipient.isSelfSet && Recipient.self().id == recipientId } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index c6c098e351..cb7fc0de9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1257,5 +1257,48 @@ object RemoteConfig { hotSwappable = true ) + /** + * Whether or not to receive admin delete messages. + */ + @JvmStatic + @get:JvmName("receiveAdminDelete") + val receiveAdminDelete: Boolean by remoteBoolean( + key = "android.receiveAdminDelete", + defaultValue = false, + hotSwappable = true + ) + + /** + * Whether or not to send admin delete messages. + */ + @JvmStatic + @get:JvmName("sendAdminDelete") + val sendAdminDelete: Boolean by remoteBoolean( + key = "android.sendAdminDelete", + defaultValue = false, + hotSwappable = true + ) + + /** + * Maximum time that passes where a message can still be regularly deleted + */ + @JvmStatic + @get:JvmName("regularDeleteThreshold") + val regularDeleteThreshold: Long by remoteLong( + key = "android.regularDeleteThreshold", + defaultValue = 1.days.inWholeMilliseconds, + hotSwappable = true + ) + + /** + * Maximum time that passes where a message can still be deleted by an admin + */ + @JvmStatic + @get:JvmName("adminDeleteThreshold") + val adminDeleteThreshold: Long by remoteLong( + key = "android.adminDeleteThreshold", + defaultValue = 1.days.inWholeMilliseconds, + hotSwappable = true + ) // endregion } diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 98cc7235dc..45e782424c 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -500,6 +500,7 @@ message ChatItem { ViewOnceMessage viewOnceMessage = 18; DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up Poll poll = 20; + AdminDeletedMessage adminDeletedMessage = 22; } PinDetails pinDetails = 21; // only set if message is pinned @@ -900,6 +901,10 @@ message Poll { repeated Reaction reactions = 5; } +message AdminDeletedMessage { + uint64 adminId = 1; // id of the admin that deleted the message +} + message ChatUpdateMessage { // If unset, importers should ignore the update message without throwing an error. oneof update { diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 40af718e57..594d6a5d2f 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -267,4 +267,10 @@ message CallQualitySurveySubmissionJobData { message CheckKeyTransparencyJobData{ bool showFailure = 1; -} \ No newline at end of file +} + +message AdminDeleteJobData { + uint64 messageId = 1; + repeated uint64 recipientIds = 2; + uint32 initialRecipientCount = 3; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3e9c1456c..5c6fa576cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -450,8 +450,12 @@   Download More   Pending - This message was deleted. - You deleted this message. + This message was deleted + You deleted this message + + Admin + + deleted this message Can\'t download message. %1$s will need to send it again. @@ -618,6 +622,20 @@ Deleting messages… Delete for me Delete for everyone + + + Delete selected message? + Delete selected messages? + + + + Who would you like to delete this message for? + Who would you like to delete these messages for? + + + Delete for everyone? + + As an admin, group members will see that you deleted these messages. Delete on this device @@ -3036,8 +3054,12 @@ View-once photo View-once video View-once media - This message was deleted. - You deleted this message. + + This message was deleted + + You deleted this message + + Admin %1$s deleted this message You sent a request to activate Payments diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt index 126bdfba39..97f3f47a61 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.mms.OutgoingMessage @@ -101,17 +100,4 @@ object TestMms { return db.insert(MessageTable.TABLE_NAME, 0, contentValues) } - - fun markAsRemoteDelete(db: SQLiteDatabase, messageId: Long) { - val values = ContentValues() - values.put(MessageTable.REMOTE_DELETED, 1) - values.putNull(MessageTable.BODY) - values.putNull(MessageTable.QUOTE_BODY) - values.putNull(MessageTable.QUOTE_AUTHOR) - values.put(MessageTable.QUOTE_TYPE, -1) - values.putNull(MessageTable.QUOTE_ID) - values.putNull(MessageTable.LINK_PREVIEWS) - values.putNull(MessageTable.SHARED_CONTACTS) - db.update(MessageTable.TABLE_NAME, values, DatabaseTable.ID_WHERE, arrayOf(messageId.toString())) - } } diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 265225a6f3..5513f7c9ee 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -188,7 +188,6 @@ object FakeMessageRecords { linkPreviews, unidentified, reactions, - remoteDelete, mentionsSelf, notifiedTimestamp, viewed, @@ -206,6 +205,7 @@ object FakeMessageRecords { 0, false, 0, + null, null ) } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index ff5c8e9ef3..3fe0368366 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1305,6 +1305,14 @@ public class SignalServiceMessageSender { .build()); } + if (message.getAdminDelete().isPresent()) { + SignalServiceDataMessage.AdminDelete adminDelete = message.getAdminDelete().get(); + builder.adminDelete(new DataMessage.AdminDelete.Builder() + .targetAuthorAciBinary(adminDelete.getTargetAuthor().toByteString()) + .targetSentTimestamp(adminDelete.getTargetSentTimestamp()) + .build()); + } + builder.timestamp(message.getTimestamp()); return builder; diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt index 41abbace5b..33ab462fb0 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt @@ -165,6 +165,10 @@ object EnvelopeContentValidator { return Result.Invalid("[DataMessage] Invalid unpin message!") } + if (dataMessage.adminDelete != null && (dataMessage.adminDelete.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.adminDelete.targetSentTimestamp == null)) { + return Result.Invalid("[DataMessage] Invalid admin delete message!") + } + return Result.Valid } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt index afa9e246f0..75919ec457 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt @@ -55,7 +55,8 @@ class SignalServiceDataMessage private constructor( val pollVote: Optional, val pollTerminate: Optional, val pinnedMessage: Optional, - val unpinnedMessage: Optional + val unpinnedMessage: Optional, + val adminDelete: Optional ) { val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false) val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false) @@ -112,6 +113,7 @@ class SignalServiceDataMessage private constructor( private var pollTerminate: PollTerminate? = null private var pinnedMessage: PinnedMessage? = null private var unpinnedMessage: UnpinnedMessage? = null + private var adminDelete: AdminDelete? = null fun withTimestamp(timestamp: Long): Builder { this.timestamp = timestamp @@ -260,6 +262,11 @@ class SignalServiceDataMessage private constructor( return this } + fun withAdminDelete(adminDelete: AdminDelete?): Builder { + this.adminDelete = adminDelete + return this + } + fun build(): SignalServiceDataMessage { if (timestamp == 0L) { timestamp = System.currentTimeMillis() @@ -293,7 +300,8 @@ class SignalServiceDataMessage private constructor( pollVote = pollVote.asOptional(), pollTerminate = pollTerminate.asOptional(), pinnedMessage = pinnedMessage.asOptional(), - unpinnedMessage = unpinnedMessage.asOptional() + unpinnedMessage = unpinnedMessage.asOptional(), + adminDelete = adminDelete.asOptional() ) } } @@ -342,6 +350,7 @@ class SignalServiceDataMessage private constructor( data class PollTerminate(val targetSentTimestamp: Long) data class PinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val pinDurationInSeconds: Int?, val forever: Boolean?) data class UnpinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long) + data class AdminDelete(val targetAuthor: ServiceId, val targetSentTimestamp: Long) companion object { @JvmStatic diff --git a/lib/libsignal-service/src/main/protowire/SignalService.proto b/lib/libsignal-service/src/main/protowire/SignalService.proto index 96654bb2f3..aa0bc151aa 100644 --- a/lib/libsignal-service/src/main/protowire/SignalService.proto +++ b/lib/libsignal-service/src/main/protowire/SignalService.proto @@ -349,6 +349,11 @@ message DataMessage { optional uint64 targetSentTimestamp = 2; } + message AdminDelete { + optional bytes targetAuthorAciBinary = 1; // 16-byte UUID + optional uint64 targetSentTimestamp = 2; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; reserved /*groupV1*/ 3; @@ -376,7 +381,8 @@ message DataMessage { optional PollVote pollVote = 26; optional PinMessage pinMessage = 27; optional UnpinMessage unpinMessage = 28; - // NEXT ID: 29 + optional AdminDelete adminDelete = 29; + // NEXT ID: 30 } message NullMessage {