Add support for admin delete.

This commit is contained in:
Michelle Tang
2026-02-20 14:44:34 -05:00
committed by Cody Henthorne
parent 1968438ebb
commit 071fbfd916
45 changed files with 648 additions and 132 deletions
@@ -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_") }
@@ -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)!!
@@ -105,7 +105,6 @@ class ConversationElementGenerator {
false,
emptyList(),
false,
false,
now,
true,
now,
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
0,
false,
0,
null,
null
)
@@ -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"
}
@@ -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")
@@ -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<Long>, 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<Long>, 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) +
@@ -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
@@ -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<MessageRecord> previousRecord,
@NonNull Optional<MessageRecord> nextRecord,
@@ -1668,7 +1699,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
List<Annotation> 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());
}
}
@@ -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);
}
/**
@@ -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
@@ -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
@@ -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 {
@@ -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<String> = 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
)
}
@@ -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<RecipientId, SentStorySyncManifest.Entry> = 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<DistributionId> = if (isRemoteDeleted) emptyList() else listOf(distributionId)
@@ -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 {
@@ -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) {
@@ -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)
}
}
@@ -53,13 +53,13 @@ public class InMemoryMessageRecord extends MessageRecord {
false,
false,
Collections.emptyList(),
false,
0,
false,
-1,
null,
0,
0,
null,
null);
}
@@ -108,12 +108,12 @@ public abstract class MessageRecord extends DisplayRecord {
private final boolean unidentified;
private final List<ReactionRecord> 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<ReactionRecord> 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<ReactionRecord> getReactions() {
@@ -103,7 +103,6 @@ public class MmsMessageRecord extends MessageRecord {
@NonNull List<LinkPreview> linkPreviews,
boolean unidentified,
@NonNull List<ReactionRecord> 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<ReactionRecord> 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<DatabaseAttachment> 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<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
@@ -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();
@@ -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'),
@@ -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<Long>,
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<Recipient>,
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<AdminDeleteSendJob?> {
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
)
}
}
}
@@ -127,6 +127,7 @@ public final class JobManagerFactories {
public static Map<String, Job.Factory> 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());
@@ -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)
@@ -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
}
@@ -56,7 +56,8 @@ object SignalServiceProtoUtil {
pollVote != null ||
pollTerminate != null ||
pinMessage != null ||
unpinMessage != null
unpinMessage != null ||
adminDelete != null
}
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean
@@ -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!!)
}
@@ -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;
}
@@ -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<RecipientId> filterRecipientIds) {
if (!messageRecord.isMms()) throw new AssertionError("Not Group");
sendGroupPush(context, messageRecord.getToRecipient(), messageRecord.getId(), filterRecipientIds, Collections.emptyList());
@@ -32,7 +32,8 @@ object DeleteDialog {
messageRecords: Set<MessageRecord>,
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<Pair<Boolean, Boolean>> = 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<MessageRecord>, emitter: SingleEmitter<Pair<Boolean, Boolean>>) {
private fun handleAdminDeleteForEveryone(context: Context, messageRecords: Set<MessageRecord>, emitter: SingleEmitter<Pair<Boolean, Boolean>>) {
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<MessageRecord>, isAdminDelete: Boolean, emitter: SingleEmitter<Pair<Boolean, Boolean>>) {
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<MessageRecord>, emitter: SingleEmitter<Pair<Boolean, Boolean>>) {
private fun deleteForEveryone(messageRecords: Set<MessageRecord>, isAdminDelete: Boolean, emitter: SingleEmitter<Pair<Boolean, Boolean>>) {
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))
@@ -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<MessageRecord>, 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
}
@@ -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
}
+5
View File
@@ -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 {
+7 -1
View File
@@ -267,4 +267,10 @@ message CallQualitySurveySubmissionJobData {
message CheckKeyTransparencyJobData{
bool showFailure = 1;
}
}
message AdminDeleteJobData {
uint64 messageId = 1;
repeated uint64 recipientIds = 2;
uint32 initialRecipientCount = 3;
}
+26 -4
View File
@@ -450,8 +450,12 @@
<!-- Footer shown at the end of long body messages to download more of it -->
<string name="ConversationItem_download_more">&#160; Download More</string>
<string name="ConversationItem_pending">&#160; Pending</string>
<string name="ConversationItem_this_message_was_deleted">This message was deleted.</string>
<string name="ConversationItem_you_deleted_this_message">You deleted this message.</string>
<string name="ConversationItem_this_message_was_deleted">This message was deleted</string>
<string name="ConversationItem_you_deleted_this_message">You deleted this message</string>
<!-- First part of a conversation message when a message has been deleted by an admin. -->
<string name="ConversationItem_admin">Admin</string>
<!-- Second part of a conversation message when a message has been deleted by an admin. -->
<string name="ConversationItem_deleted_this_message">deleted this message</string>
<!-- Dialog error message shown when user can\'t download a message from someone else due to a permanent failure (e.g., unable to decrypt), placeholder is other\'s name -->
<string name="ConversationItem_cant_download_message_s_will_need_to_send_it_again">Can\'t download message. %1$s will need to send it again.</string>
<!-- Dialog error message shown when user can\'t download an image message from someone else due to a permanent failure (e.g., unable to decrypt), placeholder is other\'s name -->
@@ -618,6 +622,20 @@
<string name="ConversationFragment_deleting_messages">Deleting messages…</string>
<string name="ConversationFragment_delete_for_me">Delete for me</string>
<string name="ConversationFragment_delete_for_everyone">Delete for everyone</string>
<!-- Title of dialog confirming whether to delete the message -->
<plurals name="ConversationFragment_delete_selected_title">
<item quantity="one">Delete selected message?</item>
<item quantity="other">Delete selected messages?</item>
</plurals>
<!-- Body of dialog confirming whether to delete the message -->
<plurals name="ConversationFragment_delete_selected_body">
<item quantity="one">Who would you like to delete this message for?</item>
<item quantity="other">Who would you like to delete these messages for?</item>
</plurals>
<!-- Confirmation button to delete the message -->
<string name="ConversationFragment_delete_for_everyone_title">Delete for everyone?</string>
<!-- Body of dialog explaining what happens during deletion -->
<string name="ConversationFragment_delete_for_everyone_body">As an admin, group members will see that you deleted these messages.</string>
<!-- Dialog button for deleting one or more note-to-self messages only on this device, leaving that same message intact on other devices. -->
<string name="ConversationFragment_delete_on_this_device">Delete on this device</string>
<!-- Dialog button for deleting one or more note-to-self messages that will be sync\'d to other devices -->
@@ -3036,8 +3054,12 @@
<string name="ThreadRecord_view_once_photo">View-once photo</string>
<string name="ThreadRecord_view_once_video">View-once video</string>
<string name="ThreadRecord_view_once_media">View-once media</string>
<string name="ThreadRecord_this_message_was_deleted">This message was deleted.</string>
<string name="ThreadRecord_you_deleted_this_message">You deleted this message.</string>
<!-- Thread preview when someone has deleted their own message -->
<string name="ThreadRecord_this_message_was_deleted">This message was deleted</string>
<!-- Thread preview when you deleted a message -->
<string name="ThreadRecord_you_deleted_this_message">You deleted this message</string>
<!-- Thread preview when an admin has deleted a message -->
<string name="ThreadRecord_admin_deleted_this_message">Admin %1$s deleted this message</string>
<!-- Displayed in the notification when the user sends a request to activate payments -->
<string name="ThreadRecord_you_sent_request">You sent a request to activate Payments</string>
<!-- Displayed in the notification when the recipient wants to activate payments -->
@@ -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()))
}
}
@@ -188,7 +188,6 @@ object FakeMessageRecords {
linkPreviews,
unidentified,
reactions,
remoteDelete,
mentionsSelf,
notifiedTimestamp,
viewed,
@@ -206,6 +205,7 @@ object FakeMessageRecords {
0,
false,
0,
null,
null
)
}
@@ -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;
@@ -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
}
@@ -55,7 +55,8 @@ class SignalServiceDataMessage private constructor(
val pollVote: Optional<PollVote>,
val pollTerminate: Optional<PollTerminate>,
val pinnedMessage: Optional<PinnedMessage>,
val unpinnedMessage: Optional<UnpinnedMessage>
val unpinnedMessage: Optional<UnpinnedMessage>,
val adminDelete: Optional<AdminDelete>
) {
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
@@ -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 {