From 09003d85b1ae5bff3ce64e7999f5846bc542e116 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 18 Jun 2024 10:02:03 -0400 Subject: [PATCH] Add single attachment delete sync. --- .../database/UriAttachmentBuilder.kt | 36 ++-- .../securesms/messages/MessageHelper.kt | 40 ++++- ...ageProcessorTest_synchronizeDeleteForMe.kt | 157 ++++++++++++++++++ .../securesms/testing/MessageContentFuzzer.kt | 35 +++- .../securesms/testing/TestUtils.kt | 32 ++-- .../securesms/attachments/UriAttachment.kt | 6 +- .../securesms/database/AttachmentTable.kt | 46 +++++ .../securesms/database/MessageTable.kt | 9 + .../jobs/MultiDeviceDeleteSendSyncJob.kt | 108 +++++++++++- .../messages/SyncMessageProcessor.kt | 40 +++++ .../securesms/util/AttachmentUtil.java | 4 + app/src/main/protowire/JobData.proto | 8 + .../src/main/protowire/SignalService.proto | 9 + 13 files changed, 492 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/UriAttachmentBuilder.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/UriAttachmentBuilder.kt index 828b75fe71..a51693a2ee 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/UriAttachmentBuilder.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/UriAttachmentBuilder.kt @@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.attachments.UriAttachment import org.thoughtcrime.securesms.audio.AudioHash import org.thoughtcrime.securesms.blurhash.BlurHash import org.thoughtcrime.securesms.stickers.StickerLocator +import java.util.UUID object UriAttachmentBuilder { fun build( @@ -22,23 +23,28 @@ object UriAttachmentBuilder { stickerLocator: StickerLocator? = null, blurHash: BlurHash? = null, audioHash: AudioHash? = null, - transformProperties: AttachmentTable.TransformProperties? = null + transformProperties: AttachmentTable.TransformProperties? = null, + uuid: UUID? = UUID.randomUUID() ): UriAttachment { return UriAttachment( - uri, - contentType, - transferState, - size, - fileName, - voiceNote, - borderless, - videoGif, - quote, - caption, - stickerLocator, - blurHash, - audioHash, - transformProperties + dataUri = uri, + contentType = contentType, + transferState = transferState, + size = size, + width = 0, + height = 0, + fileName = fileName, + fastPreflightId = null, + voiceNote = voiceNote, + borderless = borderless, + videoGif = videoGif, + quote = quote, + caption = caption, + stickerLocator = stickerLocator, + blurHash = blurHash, + audioHash = audioHash, + transformProperties = transformProperties, + uuid = uuid ) } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt index 6c7d771588..64f83a77e7 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/MessageHelper.kt @@ -5,23 +5,31 @@ package org.thoughtcrime.securesms.messages +import android.net.Uri import io.mockk.every import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkStatic +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.UriAttachment +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.UriAttachmentBuilder import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription import org.thoughtcrime.securesms.jobs.ThreadUpdateJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testing.GroupTestingUtils import org.thoughtcrime.securesms.testing.MessageContentFuzzer import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.util.MediaUtil import java.util.UUID +import kotlin.random.Random /** * Makes inserting messages through the "normal" code paths simpler. Mostly focused on incoming messages. @@ -68,7 +76,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long return messageData } - fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: (OutgoingMessage.() -> OutgoingMessage)? = null): MessageData { + fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: ((OutgoingMessage) -> OutgoingMessage)? = null): MessageData { startTime = nextStartTime() val messageData = MessageData(author = harness.self.id, timestamp = startTime) @@ -80,7 +88,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long sentTimeMillis = messageData.timestamp, isUrgent = true, isSecure = true - ).apply { updateMessage?.invoke(this) } + ).let { updateMessage?.invoke(it) ?: it } val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient) val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null) @@ -111,6 +119,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long return messageData.copy(messageId = messageId) } + fun outgoingAttachment(data: ByteArray, uuid: UUID? = UUID.randomUUID()): Attachment { + val uri: Uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory() + + val attachment: UriAttachment = UriAttachmentBuilder.build( + id = Random.nextLong(), + uri = uri, + contentType = MediaUtil.IMAGE_JPEG, + transformProperties = AttachmentTable.TransformProperties(), + uuid = uuid + ) + + return attachment + } + fun outgoingGroupChange(): MessageData { startTime = nextStartTime() @@ -238,6 +260,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long return messageData } + fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): MessageData { + startTime = nextStartTime() + val messageData = MessageData(timestamp = startTime) + + processor.process( + envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid), + content = MessageContentFuzzer.syncDeleteForMeAttachment(conversationId, message, uuid, digest, plainTextHash), + metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2), + serverDeliveredTimestamp = messageData.timestamp + 10 + ) + + return messageData + } + /** * Get the next "sentTimestamp" for current + [nextMessageOffset]th message. Useful for early message processing and future message timestamps. */ diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt index 2606129734..c34da47ccc 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt @@ -13,7 +13,11 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.signal.core.util.logging.Log +import org.signal.core.util.update import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.SignalDatabase @@ -24,8 +28,11 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync import org.thoughtcrime.securesms.testing.SignalActivityRule import org.thoughtcrime.securesms.testing.assert import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.testing.assertIsNot import org.thoughtcrime.securesms.testing.assertIsNotNull +import org.thoughtcrime.securesms.testing.assertIsSize import org.thoughtcrime.securesms.util.IdentityUtil +import java.util.UUID @Suppress("ClassName") @RunWith(AndroidJUnit4::class) @@ -531,4 +538,154 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe { harness.inMemoryLogger.flush() harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }.size assertIs 1 } + + @Test + fun singleAttachmentDeletes() { + // GIVEN + val message1 = messageHelper.outgoingText { message -> + message.copy( + attachments = listOf( + messageHelper.outgoingAttachment(byteArrayOf(1, 2, 3)), + messageHelper.outgoingAttachment(byteArrayOf(2, 3, 4), null), + messageHelper.outgoingAttachment(byteArrayOf(5, 6, 7), null), + messageHelper.outgoingAttachment(byteArrayOf(10, 11, 12)) + ) + ) + } + + var attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + attachments assertIsSize 4 + + val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!! + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1 + + // Has all three + SignalDatabase.attachments.finalizeAttachmentAfterUpload( + id = attachments[0].attachmentId, + attachment = attachments[0].copy(digest = byteArrayOf(attachments[0].attachmentId.id.toByte())), + uploadTimestamp = message1.timestamp + 1 + ) + + // Missing uuid and digest + SignalDatabase.attachments.finalizeAttachmentAfterUpload( + id = attachments[1].attachmentId, + attachment = attachments[1], + uploadTimestamp = message1.timestamp + 1 + ) + + // Missing uuid and plain text + SignalDatabase.attachments.finalizeAttachmentAfterUpload( + id = attachments[2].attachmentId, + attachment = attachments[2].copy(digest = byteArrayOf(attachments[2].attachmentId.id.toByte())), + uploadTimestamp = message1.timestamp + 1 + ) + SignalDatabase.rawDatabase.update(AttachmentTable.TABLE_NAME).values(AttachmentTable.DATA_HASH_END to null).where("${AttachmentTable.ID} = ?", attachments[2].attachmentId).run() + + // Different has all three + SignalDatabase.attachments.finalizeAttachmentAfterUpload( + id = attachments[3].attachmentId, + attachment = attachments[3].copy(digest = byteArrayOf(attachments[3].attachmentId.id.toByte())), + uploadTimestamp = message1.timestamp + 1 + ) + + attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + + // WHEN + messageHelper.syncDeleteForMeAttachment( + conversationId = messageHelper.alice, + message = message1.author to message1.timestamp, + attachments[0].uuid, + attachments[0].remoteDigest, + attachments[0].dataHash + ) + + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1 + var updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + updatedAttachments assertIsSize 3 + updatedAttachments.forEach { it.attachmentId assertIsNot attachments[0].attachmentId } + + messageHelper.syncDeleteForMeAttachment( + conversationId = messageHelper.alice, + message = message1.author to message1.timestamp, + attachments[1].uuid, + attachments[1].remoteDigest, + attachments[1].dataHash + ) + + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1 + updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + updatedAttachments assertIsSize 2 + updatedAttachments.forEach { it.attachmentId assertIsNot attachments[1].attachmentId } + + messageHelper.syncDeleteForMeAttachment( + conversationId = messageHelper.alice, + message = message1.author to message1.timestamp, + attachments[2].uuid, + attachments[2].remoteDigest, + attachments[2].dataHash + ) + + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1 + updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + updatedAttachments assertIsSize 1 + updatedAttachments.forEach { it.attachmentId assertIsNot attachments[2].attachmentId } + + messageHelper.syncDeleteForMeAttachment( + conversationId = messageHelper.alice, + message = message1.author to message1.timestamp, + attachments[3].uuid, + attachments[3].remoteDigest, + attachments[3].dataHash + ) + + SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0 + updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId) + updatedAttachments assertIsSize 0 + + SignalDatabase.threads.getThreadRecord(threadId) assertIs null + } + + private fun DatabaseAttachment.copy( + uuid: UUID? = this.uuid, + digest: ByteArray? = this.remoteDigest + ): Attachment { + return DatabaseAttachment( + attachmentId = this.attachmentId, + mmsId = this.mmsId, + hasData = this.hasData, + hasThumbnail = false, + hasArchiveThumbnail = false, + contentType = this.contentType, + transferProgress = this.transferState, + size = this.size, + fileName = this.fileName, + cdn = this.cdn, + location = this.remoteLocation, + key = this.remoteKey, + digest = digest, + incrementalDigest = this.incrementalDigest, + incrementalMacChunkSize = this.incrementalMacChunkSize, + fastPreflightId = this.fastPreflightId, + voiceNote = this.voiceNote, + borderless = this.borderless, + videoGif = this.videoGif, + width = this.width, + height = this.height, + quote = this.quote, + caption = this.caption, + stickerLocator = this.stickerLocator, + blurHash = this.blurHash, + audioHash = this.audioHash, + transformProperties = this.transformProperties, + displayOrder = this.displayOrder, + uploadTimestamp = this.uploadTimestamp, + dataHash = this.dataHash, + archiveCdn = this.archiveCdn, + archiveThumbnailCdn = this.archiveThumbnailCdn, + archiveMediaName = this.archiveMediaName, + archiveMediaId = this.archiveMediaId, + thumbnailRestoreState = this.thumbnailRestoreState, + uuid = uuid + ) + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt index c9caf60bdc..f615c9bab8 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MessageContentFuzzer.kt @@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.testing import okio.ByteString import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith import org.thoughtcrime.securesms.messages.TestMessage import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata +import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.AttachmentPointer import org.whispersystems.signalservice.internal.push.BodyRange import org.whispersystems.signalservice.internal.push.Content @@ -229,6 +232,35 @@ object MessageContentFuzzer { ).build() } + fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): Content { + val conversation = Recipient.resolved(conversationId) + + return Content + .Builder() + .syncMessage( + SyncMessage( + deleteForMe = SyncMessage.DeleteForMe( + attachmentDeletes = listOf( + SyncMessage.DeleteForMe.AttachmentDelete( + conversation = if (conversation.isGroup) { + SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString()) + } else { + SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString()) + }, + targetMessage = SyncMessage.DeleteForMe.AddressableMessage( + authorServiceId = Recipient.resolved(message.first).requireAci().toString(), + sentTimestamp = message.second + ), + uuid = uuid?.let { UuidUtil.toByteString(it) }, + fallbackDigest = digest?.toByteString(), + fallbackPlaintextHash = plainTextHash?.let { Base64.decodeOrNull(it)?.toByteString() } + ) + ) + ) + ) + ).build() + } + /** * Create a random media message that may be: * - A text body @@ -373,7 +405,8 @@ object MessageContentFuzzer { data class DeleteForMeSync( val conversationId: RecipientId, val messages: List>, - val isFullDelete: Boolean = true + val isFullDelete: Boolean = true, + val attachments: List> = emptyList() ) { constructor(conversationId: RecipientId, vararg messages: Pair) : this(conversationId, messages.toList()) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt index fc35f53e8f..49831b637f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/TestUtils.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.testing import android.database.Cursor -import android.util.Base64 import org.hamcrest.Matcher import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasSize @@ -9,6 +8,7 @@ import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.not import org.hamcrest.Matchers.notNullValue import org.hamcrest.Matchers.nullValue +import org.signal.core.util.Hex import org.signal.core.util.logging.Log import org.signal.core.util.readToList import org.signal.core.util.select @@ -67,27 +67,31 @@ fun CountDownLatch.awaitFor(duration: Duration) { } } -fun dumpTableToLogs(tag: String = "TestUtils", table: String) { - dumpTable(table).forEach { Log.d(tag, it.toString()) } +fun dumpTableToLogs(tag: String = "TestUtils", table: String, columns: Set? = null) { + dumpTable(table, columns).forEach { Log.d(tag, it.toString()) } } -fun dumpTable(table: String): List>> { +fun dumpTable(table: String, columns: Set?): List>> { return SignalDatabase.rawDatabase .select() .from(table) .run() .readToList { cursor -> - val map: List> = cursor.columnNames.map { column -> - val index = cursor.getColumnIndex(column) - var data: String? = when (cursor.getType(index)) { - Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0) - else -> cursor.getString(index) - } - if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) { - data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index)) - } + val map: List> = cursor.columnNames.mapNotNull { column -> + if (columns == null || columns.contains(column)) { + val index = cursor.getColumnIndex(column) + var data: String? = when (cursor.getType(index)) { + Cursor.FIELD_TYPE_BLOB -> Hex.toStringCondensed(cursor.getBlob(index)) + else -> cursor.getString(index) + } + if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) { + data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index)) + } - column to data + column to data + } else { + null + } } map } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt index ae8f21f32c..1998161fca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt @@ -47,6 +47,7 @@ class UriAttachment : Attachment { transformProperties = transformProperties ) + @JvmOverloads constructor( dataUri: Uri, contentType: String, @@ -64,7 +65,8 @@ class UriAttachment : Attachment { stickerLocator: StickerLocator?, blurHash: BlurHash?, audioHash: AudioHash?, - transformProperties: TransformProperties? + transformProperties: TransformProperties?, + uuid: UUID? = UUID.randomUUID() ) : super( contentType = contentType, transferState = transferState, @@ -89,7 +91,7 @@ class UriAttachment : Attachment { blurHash = blurHash, audioHash = audioHash, transformProperties = transformProperties, - uuid = UUID.randomUUID() + uuid = uuid ) { uri = Objects.requireNonNull(dataUri) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index e38528ee7c..29e4dfe418 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.crypto.AttachmentSecret import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream +import org.thoughtcrime.securesms.database.MessageTable.SyncMessageId import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages import org.thoughtcrime.securesms.database.SignalDatabase.Companion.stickers import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads @@ -655,6 +656,47 @@ class AttachmentTable( } } + fun deleteAttachments(toDelete: List): List { + val unhandled = mutableListOf() + for (syncAttachmentId in toDelete) { + val messageId = SignalDatabase.messages.getMessageIdOrNull(syncAttachmentId.syncMessageId) + if (messageId != null) { + val attachments = readableDatabase + .select(ID, ATTACHMENT_UUID, REMOTE_DIGEST, DATA_HASH_END) + .from(TABLE_NAME) + .where("$MESSAGE_ID = ?", messageId) + .run() + .readToList { + SyncAttachment( + id = AttachmentId(it.requireLong(ID)), + uuid = UuidUtil.parseOrNull(it.requireString(ATTACHMENT_UUID)), + digest = it.requireBlob(REMOTE_DIGEST), + plaintextHash = it.requireString(DATA_HASH_END) + ) + } + + val byUuid: SyncAttachment? by lazy { attachments.firstOrNull { it.uuid != null && it.uuid == syncAttachmentId.uuid } } + val byDigest: SyncAttachment? by lazy { attachments.firstOrNull { it.digest != null && it.digest.contentEquals(syncAttachmentId.digest) } } + val byPlaintext: SyncAttachment? by lazy { attachments.firstOrNull { it.plaintextHash != null && it.plaintextHash == syncAttachmentId.plaintextHash } } + + val attachmentToDelete = (byUuid ?: byDigest ?: byPlaintext)?.id + if (attachmentToDelete != null) { + if (attachments.size == 1) { + SignalDatabase.messages.deleteMessage(messageId) + } else { + deleteAttachment(attachmentToDelete) + } + } else { + Log.i(TAG, "Unable to locate sync attachment to delete for message:$messageId") + } + } else { + unhandled += syncAttachmentId.syncMessageId + } + } + + return unhandled + } + fun trimAllAbandonedAttachments() { val deleteCount = writableDatabase .delete(TABLE_NAME) @@ -2295,4 +2337,8 @@ class AttachmentTable( } } } + + class SyncAttachmentId(val syncMessageId: SyncMessageId, val uuid: UUID?, val digest: ByteArray?, val plaintextHash: String?) + + class SyncAttachment(val id: AttachmentId, val uuid: UUID?, val digest: ByteArray?, val plaintextHash: String?) } 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 58d0ef7154..95d359cfc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -3465,6 +3465,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun getMessageIdOrNull(message: SyncMessageId): Long? { + return readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$DATE_SENT = ? AND $FROM_RECIPIENT_ID = ?", message.timetamp, message.recipientId) + .run() + .readToSingleLongOrNull() + } + fun deleteMessages(messagesToDelete: List): List { val threads = mutableSetOf() val unhandled = mutableListOf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt index d9316304a9..4be723b73a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceDeleteSendSyncJob.kt @@ -8,8 +8,10 @@ package org.thoughtcrime.securesms.jobs import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 import org.signal.core.util.logging.Log import org.signal.core.util.orNull +import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -17,12 +19,14 @@ import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.AddressableMessage +import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.AttachmentDelete import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData.ThreadDelete import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.pad import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException +import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.Content import org.whispersystems.signalservice.internal.push.SyncMessage import org.whispersystems.signalservice.internal.push.SyncMessage.DeleteForMe @@ -71,6 +75,26 @@ class MultiDeviceDeleteSendSyncJob private constructor( } } + @WorkerThread + @JvmStatic + fun enqueueAttachmentDelete(message: MessageRecord?, attachment: DatabaseAttachment) { + if (!TextSecurePreferences.isMultiDevice(AppDependencies.application)) { + return + } + + if (!Recipient.self().deleteSyncCapability.isSupported) { + Log.i(TAG, "Delete sync support not enabled.") + return + } + + val delete = createAttachmentDelete(message, attachment) + if (delete != null) { + AppDependencies.jobManager.add(MultiDeviceDeleteSendSyncJob(attachments = listOf(delete))) + } else { + Log.i(TAG, "No valid attachment deletes to sync attachment:${attachment.attachmentId}") + } + } + @WorkerThread fun enqueueThreadDeletes(threads: List>>, isFullDelete: Boolean) { if (!TextSecurePreferences.isMultiDevice(AppDependencies.application)) { @@ -119,6 +143,48 @@ class MultiDeviceDeleteSendSyncJob private constructor( } } + @WorkerThread + private fun createAttachmentDelete(message: MessageRecord?, attachment: DatabaseAttachment): AttachmentDelete? { + if (message == null) { + return null + } + + val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + val addressableMessage = if (threadRecipient == null) { + Log.w(TAG, "Unable to find thread recipient for message: ${message.id} thread: ${message.threadId} attachment: ${attachment.attachmentId}") + null + } else if (threadRecipient.isReleaseNotes) { + Log.w(TAG, "Syncing release channel deletes are not currently supported") + null + } else if (threadRecipient.isDistributionList || !message.canDeleteSync()) { + null + } else { + AddressableMessage( + threadRecipientId = threadRecipient.id.toLong(), + sentTimestamp = message.dateSent, + authorRecipientId = message.fromRecipient.id.toLong() + ) + } + + if (addressableMessage == null) { + return null + } + + val delete = AttachmentDelete( + targetMessage = addressableMessage, + uuid = attachment.uuid?.let { UuidUtil.toByteString(it) }, + digest = attachment.remoteDigest?.toByteString(), + plaintextHash = attachment.dataHash?.let { Base64.decodeOrNull(it)?.toByteString() } + ) + + return if (delete.uuid == null && delete.digest == null && delete.plaintextHash == null) { + Log.w(TAG, "Unable to find uuid, digest, or plain text hash for attachment: ${attachment.attachmentId}") + null + } else { + delete + } + } + @WorkerThread private fun createThreadDeletes(threads: List>>, isFullDelete: Boolean): List { return threads.mapNotNull { (threadId, messages) -> @@ -151,12 +217,14 @@ class MultiDeviceDeleteSendSyncJob private constructor( constructor( messages: List = emptyList(), threads: List = emptyList(), - localOnlyThreads: List = emptyList() + localOnlyThreads: List = emptyList(), + attachments: List = emptyList() ) : this( DeleteSyncJobData( messageDeletes = messages, threadDeletes = threads, - localOnlyThreadDeletes = localOnlyThreads + localOnlyThreadDeletes = localOnlyThreads, + attachmentDeletes = attachments ) ) @@ -244,13 +312,45 @@ class MultiDeviceDeleteSendSyncJob private constructor( } } + if (data.attachmentDeletes.isNotEmpty()) { + val success = syncDelete( + DeleteForMe( + attachmentDeletes = data.attachmentDeletes.mapNotNull { + val conversation = Recipient.resolved(RecipientId.from(it.targetMessage!!.threadRecipientId)).toDeleteSyncConversationId() + val targetMessage = it.targetMessage.toDeleteSyncMessage() + + if (conversation != null && targetMessage != null) { + DeleteForMe.AttachmentDelete( + conversation = conversation, + targetMessage = targetMessage, + uuid = it.uuid, + fallbackDigest = it.digest, + fallbackPlaintextHash = it.plaintextHash + ) + } else { + Log.w(TAG, "Unable to resolve ${it.targetMessage.threadRecipientId} to conversation id or resolve target message data") + null + } + } + ) + ) + + if (!success) { + return Result.retry(defaultBackoff()) + } + } + return Result.success() } override fun onFailure() = Unit private fun syncDelete(deleteForMe: DeleteForMe): Boolean { - if (deleteForMe.conversationDeletes.isEmpty() && deleteForMe.messageDeletes.isEmpty() && deleteForMe.localOnlyConversationDeletes.isEmpty()) { + if (deleteForMe.conversationDeletes.isEmpty() && + deleteForMe.messageDeletes.isEmpty() && + deleteForMe.localOnlyConversationDeletes.isEmpty() && + deleteForMe.attachmentDeletes.isEmpty() + ) { Log.i(TAG, "No valid deletes, nothing to send, skipping") return true } @@ -258,7 +358,7 @@ class MultiDeviceDeleteSendSyncJob private constructor( val syncMessageContent = deleteForMeContent(deleteForMe) return try { - Log.d(TAG, "Sending delete sync messageDeletes=${deleteForMe.messageDeletes.size} conversationDeletes=${deleteForMe.conversationDeletes.size} localOnlyConversationDeletes=${deleteForMe.localOnlyConversationDeletes.size}") + Log.d(TAG, "Sending delete sync messageDeletes=${deleteForMe.messageDeletes.size} conversationDeletes=${deleteForMe.conversationDeletes.size} localOnlyConversationDeletes=${deleteForMe.localOnlyConversationDeletes.size} attachmentDeletes=${deleteForMe.attachmentDeletes.size}") AppDependencies.signalServiceMessageSender.sendSyncMessage(syncMessageContent, true, Optional.empty()).isSuccess } catch (e: IOException) { Log.w(TAG, "Unable to send message delete sync", e) 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 2240eaa263..48cb6a709f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -4,6 +4,7 @@ import ProtoUtil.isNotEmpty import android.content.Context import com.mobilecoin.lib.exceptions.SerializationException import okio.ByteString +import org.signal.core.util.Base64 import org.signal.core.util.Hex import org.signal.core.util.orNull import org.signal.libsignal.protocol.IdentityKey @@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.attachments.TombstoneAttachment import org.thoughtcrime.securesms.components.emoji.EmojiUtil import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.crypto.SecurityEvent +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.CallLinkTable import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.GroupReceiptTable @@ -104,6 +106,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.api.storage.StorageKey +import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.push.Content import org.whispersystems.signalservice.internal.push.DataMessage import org.whispersystems.signalservice.internal.push.EditMessage @@ -1489,6 +1492,10 @@ object SyncMessageProcessor { handleSynchronizeLocalOnlyConversationDeletes(deleteForMe.localOnlyConversationDeletes, envelopeTimestamp) } + if (deleteForMe.attachmentDeletes.isNotEmpty()) { + handleSynchronizeAttachmentDeletes(deleteForMe.attachmentDeletes, envelopeTimestamp, earlyMessageCacheEntry) + } + AppDependencies.messageNotifier.updateNotification(context) } @@ -1570,6 +1577,26 @@ object SyncMessageProcessor { } } + private fun handleSynchronizeAttachmentDeletes(attachmentDeletes: List, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) { + val toDelete: List = attachmentDeletes + .mapNotNull { delete -> + delete.toSyncAttachmentId(delete.targetMessage?.toSyncMessageId(envelopeTimestamp), envelopeTimestamp) + } + + val unhandled: List = SignalDatabase.attachments.deleteAttachments(toDelete) + + for (syncMessage in unhandled) { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Could not find matching message for attachment delete! timestamp: ${syncMessage.timetamp} author: ${syncMessage.recipientId}") + if (earlyMessageCacheEntry != null) { + AppDependencies.earlyMessageCache.store(syncMessage.recipientId, syncMessage.timetamp, earlyMessageCacheEntry) + } + } + + if (unhandled.isNotEmpty() && earlyMessageCacheEntry != null) { + PushProcessEarlyMessagesJob.enqueue() + } + } + private fun SyncMessage.DeleteForMe.ConversationIdentifier.toRecipientId(): RecipientId? { return when { threadGroupId != null -> { @@ -1610,4 +1637,17 @@ object SyncMessageProcessor { null } } + + private fun SyncMessage.DeleteForMe.AttachmentDelete.toSyncAttachmentId(syncMessageId: MessageTable.SyncMessageId?, envelopeTimestamp: Long): AttachmentTable.SyncAttachmentId? { + val uuid = UuidUtil.fromByteStringOrNull(uuid) + val digest = fallbackDigest?.toByteArray() + val plaintextHash = fallbackPlaintextHash?.let { Base64.encodeWithPadding(it.toByteArray()) } + + if (syncMessageId == null || (uuid == null && digest == null && plaintextHash == null)) { + warn(envelopeTimestamp, "[handleSynchronizeDeleteForMe] Invalid delete sync attachment missing identifiers") + return null + } else { + return AttachmentTable.SyncAttachmentId(syncMessageId, uuid, digest, plaintextHash) + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java index 711aa92363..c6921e0eaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; +import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSendSyncJob; import org.thoughtcrime.securesms.recipients.Recipient; import java.util.Collections; @@ -103,6 +104,9 @@ public class AttachmentUtil { SignalDatabase.messages().deleteMessage(mmsId); } else { SignalDatabase.attachments().deleteAttachment(attachmentId); + if (Recipient.self().getDeleteSyncCapability().isSupported()) { + MultiDeviceDeleteSendSyncJob.enqueueAttachmentDelete(SignalDatabase.messages().getMessageRecordOrNull(mmsId), attachment); + } } return deletedMessageRecord; diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 4d6c97feda..eb0f726bd3 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -89,6 +89,13 @@ message DeleteSyncJobData { uint64 authorRecipientId = 3; } + message AttachmentDelete { + AddressableMessage targetMessage = 1; + optional bytes uuid = 2; + optional bytes digest = 3; + optional bytes plaintextHash = 4; + } + message ThreadDelete { uint64 threadRecipientId = 1; repeated AddressableMessage messages = 2; @@ -98,6 +105,7 @@ message DeleteSyncJobData { repeated AddressableMessage messageDeletes = 1; repeated ThreadDelete threadDeletes = 2; repeated ThreadDelete localOnlyThreadDeletes = 3; + repeated AttachmentDelete attachmentDeletes = 4; } message Svr3MirrorJobData { diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index b14bdd649f..43d8c3cb3d 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -676,6 +676,14 @@ message SyncMessage { repeated AddressableMessage messages = 2; } + message AttachmentDelete { + optional ConversationIdentifier conversation = 1; + optional AddressableMessage targetMessage = 2; + optional bytes uuid = 3; // The `uuid` from the `Attachment`. + optional bytes fallbackDigest = 4; + optional bytes fallbackPlaintextHash = 5; + } + message ConversationDelete { optional ConversationIdentifier conversation = 1; repeated AddressableMessage mostRecentMessages = 2; @@ -689,6 +697,7 @@ message SyncMessage { repeated MessageDeletes messageDeletes = 1; repeated ConversationDelete conversationDeletes = 2; repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; + repeated AttachmentDelete attachmentDeletes = 4; } optional Sent sent = 1;