Add single attachment delete sync.

This commit is contained in:
Cody Henthorne
2024-06-18 10:02:03 -04:00
committed by Greyson Parrelli
parent ea87108def
commit 09003d85b1
13 changed files with 492 additions and 38 deletions

View File

@@ -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
)
}
}

View File

@@ -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<RecipientId, Long>, 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.
*/

View File

@@ -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
)
}
}

View File

@@ -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<RecipientId, Long>, 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<Pair<RecipientId, Long>>,
val isFullDelete: Boolean = true
val isFullDelete: Boolean = true,
val attachments: List<Pair<Long, AttachmentTable.SyncAttachmentId>> = emptyList()
) {
constructor(conversationId: RecipientId, vararg messages: Pair<RecipientId, Long>) : this(conversationId, messages.toList())
}

View File

@@ -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<String>? = null) {
dumpTable(table, columns).forEach { Log.d(tag, it.toString()) }
}
fun dumpTable(table: String): List<List<Pair<String, String?>>> {
fun dumpTable(table: String, columns: Set<String>?): List<List<Pair<String, String?>>> {
return SignalDatabase.rawDatabase
.select()
.from(table)
.run()
.readToList { cursor ->
val map: List<Pair<String, String?>> = 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<Pair<String, String?>> = 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
}