diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt index f1a0d7eb68..d01cd4fe72 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2 +import io.mockk.InternalPlatformDsl.toArray import okio.ByteString.Companion.toByteString import org.junit.Assert import org.junit.Before @@ -22,8 +23,10 @@ import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.Contact import org.thoughtcrime.securesms.backup.v2.proto.DistributionList import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.backup.v2.proto.Group +import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction @@ -740,6 +743,46 @@ class ImportExportTest { compare(expected, exported) } + @Test + fun messageWithAttachmentsAndQuoteAttachments() { + var dateSent = System.currentTimeMillis() + importExport( + *standardFrames, + alice, + buildChat(alice, 1), + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = dateSent++, + sms = false, + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = listOf(SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, lastStatusUpdateTimestamp = -1)) + ), + standardMessage = StandardMessage( + attachments = listOf( + MessageAttachment( + pointer = FilePointer( + attachmentLocator = FilePointer.AttachmentLocator( + cdnKey = "coolCdnKey", + cdnNumber = 2, + uploadTimestamp = System.currentTimeMillis() + ), + key = (1..32).map { it.toByte() }.toByteArray().toByteString(), + contentType = "image/png", + size = 12345, + fileName = "very_cool_picture.png", + width = 100, + height = 200, + caption = "Love this cool picture!", + incrementalMacChunkSize = 0 + ) + ) + ) + ) + ) + ) + } + @Test fun simpleChatUpdateMessage() { var dateSentStart = 100L diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index a242b3ccb7..6813fce25b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -17,12 +17,15 @@ import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt import org.signal.core.util.requireLong import org.signal.core.util.requireString +import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction @@ -106,6 +109,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: val reactionsById: Map> = SignalDatabase.reactions.getReactionsForMessages(records.keys) val mentionsById: Map> = SignalDatabase.mentions.getMentionsForMessages(records.keys) + val attachmentsById: Map> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys) val groupReceiptsById: Map> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys) for ((id, record) in records) { @@ -240,11 +244,11 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } } - record.body == null -> { - Log.w(TAG, "Record missing a body, skipping") + record.body == null && !attachmentsById.containsKey(record.id) -> { + Log.w(TAG, "Record missing a body and doesnt have attachments, skipping") continue } - else -> builder.standardMessage = record.toTextMessage(reactionsById[id], mentions = mentionsById[id]) + else -> builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id]) } buffer += builder.build() @@ -298,13 +302,21 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } - private fun BackupMessageRecord.toTextMessage(reactionRecords: List?, mentions: List?): StandardMessage { - return StandardMessage( - quote = this.toQuote(), - text = Text( - body = this.body!!, + private fun BackupMessageRecord.toStandardMessage(reactionRecords: List?, mentions: List?, attachments: List?): StandardMessage { + val text = if (body == null) { + null + } else { + Text( + body = this.body, bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList()) - ), + ) + } + val quotedAttachments = attachments?.filter { it.quote } ?: emptyList() + val messageAttachments = attachments?.filter { !it.quote } ?: emptyList() + return StandardMessage( + quote = this.toQuote(quotedAttachments), + text = text, + attachments = messageAttachments.toBackupAttachments(), // TODO Link previews! linkPreview = emptyList(), longText = null, @@ -312,14 +324,14 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: ) } - private fun BackupMessageRecord.toQuote(): Quote? { + private fun BackupMessageRecord.toQuote(attachments: List? = null): Quote? { return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) { - // TODO Attachments! val type = QuoteModel.Type.fromCode(this.quoteType) Quote( targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID }, authorId = this.quoteAuthor, text = this.quoteBody, + attachments = attachments?.toBackupQuoteAttachments() ?: emptyList(), bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(), type = when (type) { QuoteModel.Type.NORMAL -> Quote.Type.NORMAL @@ -331,6 +343,44 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } + private fun List.toBackupQuoteAttachments(): List { + return this.map { attachment -> + Quote.QuotedAttachment( + contentType = attachment.contentType, + fileName = attachment.fileName, + thumbnail = attachment.toBackupAttachment() + ) + } + } + + private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment { + return MessageAttachment( + pointer = FilePointer( + attachmentLocator = FilePointer.AttachmentLocator( + cdnKey = this.remoteLocation ?: "", + cdnNumber = this.cdnNumber, + uploadTimestamp = this.uploadTimestamp + ), + key = if (remoteKey != null) decode(remoteKey).toByteString() else null, + contentType = this.contentType, + size = this.size.toInt(), + incrementalMac = this.incrementalDigest?.toByteString(), + incrementalMacChunkSize = this.incrementalMacChunkSize, + fileName = this.fileName, + width = this.width, + height = this.height, + caption = this.caption, + blurHash = this.blurHash?.hash + ) + ) + } + + private fun List.toBackupAttachments(): List { + return this.map { attachment -> + attachment.toBackupAttachment() + } + } + private fun List.toBackupBodyRanges(): List { return this.map { BackupBodyRange( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 6f3d46dba6..8c6df58438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -10,13 +10,17 @@ import androidx.core.content.contentValuesOf import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil import org.signal.core.util.logging.Log +import org.signal.core.util.orNull import org.signal.core.util.requireLong import org.signal.core.util.toInt +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.backup.v2.BackupState import org.thoughtcrime.securesms.backup.v2.proto.BodyRange import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment import org.thoughtcrime.securesms.backup.v2.proto.Quote import org.thoughtcrime.securesms.backup.v2.proto.Reaction import org.thoughtcrime.securesms.backup.v2.proto.SendStatus @@ -44,8 +48,12 @@ import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.JsonUtils +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.util.UuidUtil +import java.util.Optional /** * An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them @@ -228,6 +236,17 @@ class ChatItemImportInserter( } } } + val attachments = this.standardMessage.attachments.mapNotNull { attachment -> + attachment.toLocalAttachment() + } + val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull { + it.toLocalAttachment() + } ?: emptyList() + if (attachments.isNotEmpty()) { + followUp = { messageRowId -> + SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments, quoteAttachments) + } + } } return MessageInsert(contentValues, followUp) } @@ -544,6 +563,39 @@ class ChatItemImportInserter( } } + private fun MessageAttachment.toLocalAttachment(contentType: String? = pointer?.contentType, fileName: String? = pointer?.fileName): Attachment? { + if (pointer == null) return null + if (pointer.attachmentLocator != null) { + val signalAttachmentPointer = SignalServiceAttachmentPointer( + pointer.attachmentLocator.cdnNumber, + SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey), + contentType, + pointer.key?.toByteArray(), + Optional.ofNullable(pointer.size), + Optional.empty(), + pointer.width ?: 0, + pointer.height ?: 0, + Optional.empty(), + Optional.ofNullable(pointer.incrementalMac?.toByteArray()), + pointer.incrementalMacChunkSize ?: 0, + Optional.ofNullable(fileName), + flag == MessageAttachment.Flag.VOICE_MESSAGE, + flag == MessageAttachment.Flag.BORDERLESS, + flag == MessageAttachment.Flag.GIF, + Optional.ofNullable(pointer.caption), + Optional.ofNullable(pointer.blurHash), + pointer.attachmentLocator.uploadTimestamp + ) + return PointerAttachment.forPointer(Optional.of(signalAttachmentPointer)).orNull() + } + return null + } + + private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? { + return thumbnail?.toLocalAttachment(this.contentType, this.fileName) + ?: if (this.contentType == null) null else PointerAttachment.forPointer(SignalServiceDataMessage.Quote.QuotedAttachment(contentType = this.contentType!!, fileName = this.fileName, thumbnail = null)).orNull() + } + private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?) private class Buffer( diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index 86f39d6757..875020948f 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -423,7 +423,7 @@ message FilePointer { // Can be ignored if unset/unavailable. optional uint32 size = 7; optional bytes incrementalMac = 8; - optional bytes incrementalMacChunkSize = 9; + optional uint32 incrementalMacChunkSize = 9; optional string fileName = 10; optional uint32 width = 11; optional uint32 height = 12;